diff options
author | Kevin Serrano <kevgagser@gmail.com> | 2018-10-15 08:32:29 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-10-15 08:32:29 +0800 |
commit | 85884b21afe6e5e85b58123b24e72776d1437cc6 (patch) | |
tree | 6dbec947089691c0dffd5febbe6e92c1f712e40f /ui | |
parent | db981b827b07c946e17d3a8aeaefd2143c236ef7 (diff) | |
parent | 7f6b488c04e6ea12b2ef24ac936b6a236ad904c2 (diff) | |
download | tangerine-wallet-browser-85884b21afe6e5e85b58123b24e72776d1437cc6.tar tangerine-wallet-browser-85884b21afe6e5e85b58123b24e72776d1437cc6.tar.gz tangerine-wallet-browser-85884b21afe6e5e85b58123b24e72776d1437cc6.tar.bz2 tangerine-wallet-browser-85884b21afe6e5e85b58123b24e72776d1437cc6.tar.lz tangerine-wallet-browser-85884b21afe6e5e85b58123b24e72776d1437cc6.tar.xz tangerine-wallet-browser-85884b21afe6e5e85b58123b24e72776d1437cc6.tar.zst tangerine-wallet-browser-85884b21afe6e5e85b58123b24e72776d1437cc6.zip |
Merge pull request #5512 from MetaMask/v4.14.0
Version 4.14.0
Diffstat (limited to 'ui')
278 files changed, 7729 insertions, 3869 deletions
diff --git a/ui/app/account-and-transaction-details.js b/ui/app/account-and-transaction-details.js deleted file mode 100644 index 03101d37a..000000000 --- a/ui/app/account-and-transaction-details.js +++ /dev/null @@ -1,33 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const inherits = require('util').inherits -// Main Views -const TxView = require('./components/tx-view') -const WalletView = require('./components/wallet-view') - -module.exports = AccountAndTransactionDetails - -inherits(AccountAndTransactionDetails, Component) -function AccountAndTransactionDetails () { - Component.call(this) -} - -AccountAndTransactionDetails.prototype.render = function () { - return h('div.account-and-transaction-details', [ - // wallet - h(WalletView, { - style: { - }, - responsiveDisplayClassname: '.lap-visible', - }, [ - ]), - - // transaction - h(TxView, { - style: { - }, - }, [ - ]), - ]) -} - diff --git a/ui/app/actions.js b/ui/app/actions.js index 6bcc64e17..eea581d33 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -167,6 +167,7 @@ var actions = { updateTransaction, updateAndApproveTx, cancelTx: cancelTx, + cancelTxs, completedTx: completedTx, txError: txError, nextTx: nextTx, @@ -227,13 +228,17 @@ var actions = { SET_PROVIDER_TYPE: 'SET_PROVIDER_TYPE', showConfigPage, SHOW_ADD_TOKEN_PAGE: 'SHOW_ADD_TOKEN_PAGE', + SHOW_ADD_SUGGESTED_TOKEN_PAGE: 'SHOW_ADD_SUGGESTED_TOKEN_PAGE', showAddTokenPage, + showAddSuggestedTokenPage, addToken, addTokens, removeToken, updateTokens, + removeSuggestedTokens, UPDATE_TOKENS: 'UPDATE_TOKENS', setRpcTarget: setRpcTarget, + delRpcTarget: delRpcTarget, setProviderType: setProviderType, SET_HARDWARE_WALLET_DEFAULT_HD_PATH: 'SET_HARDWARE_WALLET_DEFAULT_HD_PATH', setHardwareWalletDefaultHdPath, @@ -312,6 +317,8 @@ var actions = { CLEAR_PENDING_TOKENS: 'CLEAR_PENDING_TOKENS', setPendingTokens, clearPendingTokens, + + createCancelTransaction, } module.exports = actions @@ -410,12 +417,18 @@ function createNewVaultAndRestore (password, seed) { log.debug(`background.createNewVaultAndRestore`) return new Promise((resolve, reject) => { - background.createNewVaultAndRestore(password, seed, err => { + background.clearSeedWordCache((err) => { if (err) { return reject(err) } - resolve() + background.createNewVaultAndRestore(password, seed, (err) => { + if (err) { + return reject(err) + } + + resolve() + }) }) }) .then(() => dispatch(actions.unMarkPasswordForgotten())) @@ -905,6 +918,7 @@ function updateGasData ({ selectedToken, to, value, + data, }) { return (dispatch) => { dispatch(actions.gasLoadingStarted()) @@ -925,6 +939,7 @@ function updateGasData ({ to, value, estimateGasPrice, + data, }), ]) }) @@ -1147,6 +1162,10 @@ function updateAndApproveTx (txData) { return txData }) + .catch((err) => { + dispatch(actions.hideLoadingIndication()) + return Promise.reject(err) + }) } } @@ -1284,6 +1303,47 @@ function cancelTx (txData) { } } +/** + * Cancels all of the given transactions + * @param {Array<object>} txDataList a list of tx data objects + * @return {function(*): Promise<void>} + */ +function cancelTxs (txDataList) { + return async (dispatch, getState) => { + dispatch(actions.showLoadingIndication()) + const txIds = txDataList.map(({id}) => id) + const cancellations = txIds.map((id) => new Promise((resolve, reject) => { + background.cancelTransaction(id, (err) => { + if (err) { + return reject(err) + } + + resolve() + }) + })) + + await Promise.all(cancellations) + const newState = await updateMetamaskStateFromBackground() + dispatch(actions.updateMetamaskState(newState)) + dispatch(actions.clearSend()) + + txIds.forEach((id) => { + dispatch(actions.completedTx(id)) + }) + + dispatch(actions.hideLoadingIndication()) + + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION) { + return global.platform.closeCurrentWindow() + } + } +} + +/** + * @deprecated + * @param {Array<object>} txsData + * @return {Function} + */ function cancelAllTx (txsData) { return (dispatch) => { txsData.forEach((txData, i) => { @@ -1589,11 +1649,18 @@ function showAddTokenPage (transitionForward = true) { } } -function addToken (address, symbol, decimals) { +function showAddSuggestedTokenPage (transitionForward = true) { + return { + type: actions.SHOW_ADD_SUGGESTED_TOKEN_PAGE, + value: transitionForward, + } +} + +function addToken (address, symbol, decimals, image) { return (dispatch) => { dispatch(actions.showLoadingIndication()) return new Promise((resolve, reject) => { - background.addToken(address, symbol, decimals, (err, tokens) => { + background.addToken(address, symbol, decimals, image, (err, tokens) => { dispatch(actions.hideLoadingIndication()) if (err) { dispatch(actions.displayWarning(err.message)) @@ -1643,6 +1710,27 @@ function addTokens (tokens) { } } +function removeSuggestedTokens () { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { + background.removeSuggestedTokens((err, suggestedTokens) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + } + dispatch(actions.clearPendingTokens()) + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION) { + return global.platform.closeCurrentWindow() + } + resolve(suggestedTokens) + }) + }) + .then(() => updateMetamaskStateFromBackground()) + .then(suggestedTokens => dispatch(actions.updateMetamaskState({...suggestedTokens}))) + } +} + function updateTokens (newTokens) { return { type: actions.UPDATE_TOKENS, @@ -1650,6 +1738,12 @@ function updateTokens (newTokens) { } } +function clearPendingTokens () { + return { + type: actions.CLEAR_PENDING_TOKENS, + } +} + function goBackToInitView () { return { type: actions.BACK_TO_INIT_MENU, @@ -1668,7 +1762,7 @@ function markNoticeRead (notice) { background.markNoticeRead(notice, (err, notice) => { dispatch(actions.hideLoadingIndication()) if (err) { - dispatch(actions.displayWarning(err)) + dispatch(actions.displayWarning(err.message)) return reject(err) } @@ -1725,6 +1819,29 @@ function retryTransaction (txId) { } } +function createCancelTransaction (txId, customGasPrice) { + log.debug('background.cancelTransaction') + let newTxId + + return dispatch => { + return new Promise((resolve, reject) => { + background.createCancelTransaction(txId, customGasPrice, (err, newState) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + reject(err) + } + + const { selectedAddressTxList } = newState + const { id } = selectedAddressTxList[selectedAddressTxList.length - 1] + newTxId = id + resolve(newState) + }) + }) + .then(newState => dispatch(actions.updateMetamaskState(newState))) + .then(() => newTxId) + } +} + // // config // @@ -1735,7 +1852,7 @@ function setProviderType (type) { background.setProviderType(type, (err, result) => { if (err) { log.error(err) - return dispatch(self.displayWarning('Had a problem changing networks!')) + return dispatch(actions.displayWarning('Had a problem changing networks!')) } dispatch(actions.updateProviderType(type)) dispatch(actions.setSelectedToken()) @@ -1757,7 +1874,20 @@ function setRpcTarget (newRpc) { background.setCustomRpc(newRpc, (err, result) => { if (err) { log.error(err) - return dispatch(self.displayWarning('Had a problem changing networks!')) + return dispatch(actions.displayWarning('Had a problem changing networks!')) + } + dispatch(actions.setSelectedToken()) + }) + } +} + +function delRpcTarget (oldRpc) { + return (dispatch) => { + log.debug(`background.delRpcTarget: ${oldRpc}`) + background.delCustomRpc(oldRpc, (err, result) => { + if (err) { + log.error(err) + return dispatch(self.displayWarning('Had a problem removing network!')) } dispatch(actions.setSelectedToken()) }) @@ -1812,9 +1942,13 @@ function hideModal (payload) { } } -function showSidebar () { +function showSidebar ({ transitionName, type }) { return { type: actions.SIDEBAR_OPEN, + value: { + transitionName, + type, + }, } } @@ -2175,6 +2309,10 @@ function updateNetworkNonce (address) { return (dispatch) => { return new Promise((resolve, reject) => { global.ethQuery.getTransactionCount(address, (err, data) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } dispatch(setNetworkNonce(data)) resolve(data) }) @@ -2262,7 +2400,7 @@ function setUseBlockie (val) { function updateCurrentLocale (key) { return (dispatch) => { dispatch(actions.showLoadingIndication()) - fetchLocale(key) + return fetchLocale(key) .then((localeMessages) => { log.debug(`background.setCurrentLocale`) background.setCurrentLocale(key, (err) => { @@ -2310,9 +2448,3 @@ function setPendingTokens (pendingTokens) { payload: tokens, } } - -function clearPendingTokens () { - return { - type: actions.CLEAR_PENDING_TOKENS, - } -} diff --git a/ui/app/app.js b/ui/app/app.js index dbb6146d1..aeb3d05ee 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -15,22 +15,22 @@ const SendTransactionScreen = require('./components/send/send.container') const ConfirmTransaction = require('./components/pages/confirm-transaction') // slideout menu -const WalletView = require('./components/wallet-view') +const Sidebar = require('./components/sidebars').default // other views -const Home = require('./components/pages/home') +import Home from './components/pages/home' +import Settings from './components/pages/settings' const Authenticated = require('./components/pages/authenticated') const Initialized = require('./components/pages/initialized') -const Settings = require('./components/pages/settings') const RestoreVaultPage = require('./components/pages/keychains/restore-vault').default const RevealSeedConfirmation = require('./components/pages/keychains/reveal-seed') const AddTokenPage = require('./components/pages/add-token') const ConfirmAddTokenPage = require('./components/pages/confirm-add-token') +const ConfirmAddSuggestedTokenPage = require('./components/pages/confirm-add-suggested-token') const CreateAccountPage = require('./components/pages/create-account') const NoticeScreen = require('./components/pages/notice') const Loading = require('./components/loading-screen') -const ReactCSSTransitionGroup = require('react-addons-css-transition-group') const NetworkDropdown = require('./components/dropdowns/network-dropdown') const AccountMenu = require('./components/account-menu') @@ -39,8 +39,7 @@ const Modal = require('./components/modals/index').Modal // Global Alert const Alert = require('./components/alert') -const AppHeader = require('./components/app-header') - +import AppHeader from './components/app-header' import UnlockPage from './components/pages/unlock-page' // Routes @@ -52,6 +51,7 @@ const { RESTORE_VAULT_ROUTE, ADD_TOKEN_ROUTE, CONFIRM_ADD_TOKEN_ROUTE, + CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE, NEW_ACCOUNT_ROUTE, SEND_ROUTE, CONFIRM_TRANSACTION_ROUTE, @@ -86,6 +86,7 @@ class App extends Component { h(Authenticated, { path: SEND_ROUTE, exact, component: SendTransactionScreen }), h(Authenticated, { path: ADD_TOKEN_ROUTE, exact, component: AddTokenPage }), h(Authenticated, { path: CONFIRM_ADD_TOKEN_ROUTE, exact, component: ConfirmAddTokenPage }), + h(Authenticated, { path: CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE, exact, component: ConfirmAddSuggestedTokenPage }), h(Authenticated, { path: NEW_ACCOUNT_ROUTE, component: CreateAccountPage }), h(Authenticated, { path: DEFAULT_ROUTE, exact, component: Home }), ]) @@ -103,6 +104,7 @@ class App extends Component { frequentRpcList, currentView, setMouseUserState, + sidebar, } = this.props const isLoadingNetwork = network === 'loading' && currentView.name !== 'config' const loadMessage = loadingMessage || isLoadingNetwork ? @@ -135,7 +137,12 @@ class App extends Component { h(AppHeader), // sidebar - this.renderSidebar(), + h(Sidebar, { + sidebarOpen: sidebar.isOpen, + hideSidebar: this.props.hideSidebar, + transitionName: sidebar.transitionName, + type: sidebar.type, + }), // network dropdown h(NetworkDropdown, { @@ -145,61 +152,18 @@ class App extends Component { h(AccountMenu), - (isLoading || isLoadingNetwork) && h(Loading, { - loadingMessage: loadMessage, - }), + h('div.main-container-wrapper', [ + (isLoading || isLoadingNetwork) && h(Loading, { + loadingMessage: loadMessage, + }), - // content - this.renderRoutes(), + // content + this.renderRoutes(), + ]), ]) ) } - renderSidebar () { - return h('div', [ - h('style', ` - .sidebar-enter { - transition: transform 300ms ease-in-out; - transform: translateX(-100%); - } - .sidebar-enter.sidebar-enter-active { - transition: transform 300ms ease-in-out; - transform: translateX(0%); - } - .sidebar-leave { - transition: transform 200ms ease-out; - transform: translateX(0%); - } - .sidebar-leave.sidebar-leave-active { - transition: transform 200ms ease-out; - transform: translateX(-100%); - } - `), - - h(ReactCSSTransitionGroup, { - transitionName: 'sidebar', - transitionEnterTimeout: 300, - transitionLeaveTimeout: 200, - }, [ - // A second instance of Walletview is used for non-mobile viewports - this.props.sidebarOpen ? h(WalletView, { - responsiveDisplayClassname: '.sidebar', - style: {}, - }) : undefined, - - ]), - - // overlay - // TODO: add onClick for overlay to close sidebar - this.props.sidebarOpen ? h('div.sidebar-overlay', { - style: {}, - onClick: () => { - this.props.hideSidebar() - }, - }, []) : undefined, - ]) - } - toggleMetamaskActive () { if (!this.props.isUnlocked) { // currently inactive: redirect to password box @@ -226,7 +190,7 @@ class App extends Component { } else if (providerName === 'ropsten') { name = this.context.t('connectingToRopsten') } else if (providerName === 'kovan') { - name = this.context.t('connectingToRopsten') + name = this.context.t('connectingToKovan') } else if (providerName === 'rinkeby') { name = this.context.t('connectingToRinkeby') } else { @@ -268,7 +232,7 @@ App.propTypes = { provider: PropTypes.object, frequentRpcList: PropTypes.array, currentView: PropTypes.object, - sidebarOpen: PropTypes.bool, + sidebar: PropTypes.object, alertOpen: PropTypes.bool, hideSidebar: PropTypes.func, isMascara: PropTypes.bool, @@ -304,7 +268,7 @@ function mapStateToProps (state) { const { appState, metamask } = state const { networkDropdownOpen, - sidebarOpen, + sidebar, alertOpen, alertMessage, isLoading, @@ -331,7 +295,7 @@ function mapStateToProps (state) { return { // state from plugin networkDropdownOpen, - sidebarOpen, + sidebar, alertOpen, alertMessage, isLoading, diff --git a/ui/app/components/app-header/app-header.component.js b/ui/app/components/app-header/app-header.component.js index 07ca6cf84..b8b002dcc 100644 --- a/ui/app/components/app-header/app-header.component.js +++ b/ui/app/components/app-header/app-header.component.js @@ -1,4 +1,4 @@ -import React, { Component } from 'react' +import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' import { matchPath } from 'react-router-dom' @@ -11,7 +11,7 @@ const { DEFAULT_ROUTE, INITIALIZE_ROUTE, CONFIRM_TRANSACTION_ROUTE } = require(' const Identicon = require('../identicon') const NetworkIndicator = require('../network') -class AppHeader extends Component { +export default class AppHeader extends PureComponent { static propTypes = { history: PropTypes.object, location: PropTypes.object, @@ -107,20 +107,19 @@ class AppHeader extends Component { onClick={() => history.push(DEFAULT_ROUTE)} > <img - className="app-header__metafox" - src="/images/metamask-fox.svg" + className="app-header__metafox-logo app-header__metafox-logo--horizontal" + src="/images/logo/metamask-logo-horizontal-beta.svg" + height={30} + /> + <img + className="app-header__metafox-logo app-header__metafox-logo--icon" + src="/images/logo/metamask-fox.svg" height={42} width={42} /> - <div className="flex-row"> - <h1>{ this.context.t('appName') }</h1> - <div className="app-header__beta-label"> - { this.context.t('beta') } - </div> - </div> </div> <div className="app-header__account-menu-container"> - <div className="network-component-wrapper"> + <div className="app-header__network-component-wrapper"> <NetworkIndicator network={network} provider={provider} @@ -135,5 +134,3 @@ class AppHeader extends Component { ) } } - -export default AppHeader diff --git a/ui/app/components/app-header/index.js b/ui/app/components/app-header/index.js index daa31f621..6de2f9c78 100644 --- a/ui/app/components/app-header/index.js +++ b/ui/app/components/app-header/index.js @@ -1,2 +1 @@ -import AppHeader from './app-header.container' -module.exports = AppHeader +export { default } from './app-header.container' diff --git a/ui/app/css/itcss/components/header.scss b/ui/app/components/app-header/index.scss index 3ccfd5c15..325844af5 100644 --- a/ui/app/css/itcss/components/header.scss +++ b/ui/app/components/app-header/index.scss @@ -30,21 +30,19 @@ } } - &__metafox { + &__metafox-logo { cursor: pointer; - } - &__beta-label { - font-family: Roboto; - text-transform: uppercase; - font-weight: 500; - font-size: .8rem; - color: $buttercup; - margin-left: 5px; - line-height: initial; + &--icon { + @media screen and (min-width: $break-large) { + display: none; + } + } - @media screen and (max-width: 575px) { - display: none; + &--horizontal { + @media screen and (max-width: $break-small) { + display: none; + } } } @@ -83,31 +81,10 @@ flex-flow: row nowrap; align-items: center; } -} - -.app-header h1 { - font-family: Roboto; - text-transform: uppercase; - font-weight: 400; - font-size: 1.1rem; - position: relative; - padding-left: 15px; - color: #5b5d67; - @media screen and (max-width: 575px) { - display: none; + &__network-component-wrapper { + display: flex; + flex-direction: row; + align-items: center; } } - -h2.page-subtitle { - text-transform: uppercase; - color: #aeaeae; - font-size: 1em; - margin: 12px; -} - -.network-component-wrapper { - display: flex; - flex-direction: row; - align-items: center; -} diff --git a/ui/app/components/balance-component.js b/ui/app/components/balance-component.js index e31552f2d..d63d78c9f 100644 --- a/ui/app/components/balance-component.js +++ b/ui/app/components/balance-component.js @@ -4,8 +4,8 @@ const h = require('react-hyperscript') const inherits = require('util').inherits const TokenBalance = require('./token-balance') const Identicon = require('./identicon') -const currencyFormatter = require('currency-formatter') -const currencies = require('currency-formatter/currencies') +import CurrencyDisplay from './currency-display' +const { getAssetImages, conversionRateSelector, getCurrentCurrency} = require('../selectors') const { formatBalance, generateBalanceObject } = require('../util') @@ -20,8 +20,9 @@ function mapStateToProps (state) { return { account, network, - conversionRate: state.metamask.conversionRate, - currentCurrency: state.metamask.currentCurrency, + conversionRate: conversionRateSelector(state), + currentCurrency: getCurrentCurrency(state), + assetImages: getAssetImages(state), } } @@ -32,7 +33,9 @@ function BalanceComponent () { BalanceComponent.prototype.render = function () { const props = this.props - const { token, network } = props + const { token, network, assetImages } = props + const address = token && token.address + const image = assetImages && address ? assetImages[token.address] : undefined return h('div.balance-container', {}, [ @@ -43,8 +46,9 @@ BalanceComponent.prototype.render = function () { // }), h(Identicon, { diameter: 50, - address: token && token.address, + address, network, + image, }), token ? this.renderTokenBalance() : this.renderBalance(), @@ -80,38 +84,12 @@ BalanceComponent.prototype.renderBalance = function () { style: {}, }, this.getTokenBalance(formattedBalance, shorten)), - showFiat ? this.renderFiatValue(formattedBalance) : null, + showFiat && h(CurrencyDisplay, { + value: balanceValue, + }), ]) } -BalanceComponent.prototype.renderFiatValue = function (formattedBalance) { - - const { conversionRate, currentCurrency } = this.props - - const fiatDisplayNumber = this.getFiatDisplayNumber(formattedBalance, conversionRate) - - const fiatPrefix = currentCurrency === 'USD' ? '$' : '' - - return this.renderFiatAmount(fiatDisplayNumber, currentCurrency, fiatPrefix) -} - -BalanceComponent.prototype.renderFiatAmount = function (fiatDisplayNumber, fiatSuffix, fiatPrefix) { - const shouldNotRenderFiat = fiatDisplayNumber === 'N/A' || Number(fiatDisplayNumber) === 0 - if (shouldNotRenderFiat) return null - - const upperCaseFiatSuffix = fiatSuffix.toUpperCase() - - const display = currencies.find(currency => currency.code === upperCaseFiatSuffix) - ? currencyFormatter.format(Number(fiatDisplayNumber), { - code: upperCaseFiatSuffix, - }) - : `${fiatPrefix}${fiatDisplayNumber} ${upperCaseFiatSuffix}` - - return h('div.fiat-amount', { - style: {}, - }, display) -} - BalanceComponent.prototype.getTokenBalance = function (formattedBalance, shorten) { const balanceObj = generateBalanceObject(formattedBalance, shorten ? 1 : 3) diff --git a/ui/app/components/button/button.component.js b/ui/app/components/button/button.component.js index 1e0ef1b64..4a06333e7 100644 --- a/ui/app/components/button/button.component.js +++ b/ui/app/components/button/button.component.js @@ -6,6 +6,7 @@ const CLASSNAME_DEFAULT = 'btn-default' const CLASSNAME_PRIMARY = 'btn-primary' const CLASSNAME_SECONDARY = 'btn-secondary' const CLASSNAME_CONFIRM = 'btn-confirm' +const CLASSNAME_RAISED = 'btn-raised' const CLASSNAME_LARGE = 'btn--large' const typeHash = { @@ -13,6 +14,7 @@ const typeHash = { primary: CLASSNAME_PRIMARY, secondary: CLASSNAME_SECONDARY, confirm: CLASSNAME_CONFIRM, + raised: CLASSNAME_RAISED, } export default class Button extends Component { @@ -20,7 +22,7 @@ export default class Button extends Component { type: PropTypes.string, large: PropTypes.bool, className: PropTypes.string, - children: PropTypes.string, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), } render () { @@ -29,6 +31,7 @@ export default class Button extends Component { return ( <button className={classnames( + 'button', typeHash[type], large && CLASSNAME_LARGE, className diff --git a/ui/app/components/buy-button-subview.js b/ui/app/components/buy-button-subview.js deleted file mode 100644 index c6957d2aa..000000000 --- a/ui/app/components/buy-button-subview.js +++ /dev/null @@ -1,267 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const inherits = require('util').inherits -const connect = require('react-redux').connect -const actions = require('../actions') -const CoinbaseForm = require('./coinbase-form') -const ShapeshiftForm = require('./shapeshift-form') -const Loading = require('./loading-screen') -const AccountPanel = require('./account-panel') -const RadioList = require('./custom-radio-list') -const { getNetworkDisplayName } = require('../../../app/scripts/controllers/network/util') - -BuyButtonSubview.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps)(BuyButtonSubview) - - -function mapStateToProps (state) { - return { - identity: state.appState.identity, - account: state.metamask.accounts[state.appState.buyView.buyAddress], - warning: state.appState.warning, - buyView: state.appState.buyView, - network: state.metamask.network, - provider: state.metamask.provider, - context: state.appState.currentView.context, - isSubLoading: state.appState.isSubLoading, - } -} - -inherits(BuyButtonSubview, Component) -function BuyButtonSubview () { - Component.call(this) -} - -BuyButtonSubview.prototype.render = function () { - return ( - h('div', { - style: { - width: '100%', - }, - }, [ - this.headerSubview(), - this.primarySubview(), - ]) - ) -} - -BuyButtonSubview.prototype.headerSubview = function () { - const props = this.props - const isLoading = props.isSubLoading - return ( - - h('.flex-column', { - style: { - alignItems: 'center', - }, - }, [ - - // header bar (back button, label) - h('.flex-row', { - style: { - alignItems: 'center', - justifyContent: 'center', - }, - }, [ - h('i.fa.fa-arrow-left.fa-lg.cursor-pointer.color-orange', { - onClick: this.backButtonContext.bind(this), - style: { - position: 'absolute', - left: '10px', - }, - }), - h('h2.text-transform-uppercase.flex-center', { - style: { - width: '100vw', - background: 'rgb(235, 235, 235)', - color: 'rgb(174, 174, 174)', - paddingTop: '4px', - paddingBottom: '4px', - }, - }, this.context.t('depositEth')), - ]), - - // loading indication - h('div', { - style: { - position: 'absolute', - top: '57vh', - left: '49vw', - }, - }, [ - isLoading && h(Loading), - ]), - - // account panel - h('div', { - style: { - width: '80%', - }, - }, [ - h(AccountPanel, { - showFullAddress: true, - identity: props.identity, - account: props.account, - }), - ]), - - h('.flex-row', { - style: { - alignItems: 'center', - justifyContent: 'center', - }, - }, [ - h('h3.text-transform-uppercase.flex-center', { - style: { - paddingLeft: '15px', - width: '100vw', - background: 'rgb(235, 235, 235)', - color: 'rgb(174, 174, 174)', - paddingTop: '4px', - paddingBottom: '4px', - }, - }, this.context.t('selectService')), - ]), - - ]) - - ) -} - - -BuyButtonSubview.prototype.primarySubview = function () { - const props = this.props - const network = props.network - - switch (network) { - case 'loading': - return - - case '1': - return this.mainnetSubview() - - // Ropsten, Rinkeby, Kovan - case '3': - case '4': - case '42': - const networkName = getNetworkDisplayName(network) - const label = `${networkName} ${this.context.t('testFaucet')}` - return ( - h('div.flex-column', { - style: { - alignItems: 'center', - margin: '20px 50px', - }, - }, [ - h('button.text-transform-uppercase', { - onClick: () => this.props.dispatch(actions.buyEth({ network })), - style: { - marginTop: '15px', - }, - }, label), - // Kovan only: Dharma loans beta - network === '42' ? ( - h('button.text-transform-uppercase', { - onClick: () => this.navigateTo('https://borrow.dharma.io/'), - style: { - marginTop: '15px', - }, - }, this.context.t('borrowDharma')) - ) : null, - ]) - ) - - default: - return ( - h('h2.error', this.context.t('unknownNetworkId')) - ) - - } -} - -BuyButtonSubview.prototype.mainnetSubview = function () { - const props = this.props - - return ( - - h('.flex-column', { - style: { - alignItems: 'center', - }, - }, [ - - h('.flex-row.selected-exchange', { - style: { - position: 'relative', - right: '35px', - marginTop: '20px', - marginBottom: '20px', - }, - }, [ - h(RadioList, { - defaultFocus: props.buyView.subview, - labels: [ - 'Coinbase', - 'ShapeShift', - ], - subtext: { - 'Coinbase': `${this.context.t('crypto')}/${this.context.t('fiat')} (${this.context.t('usaOnly')})`, - 'ShapeShift': this.context.t('crypto'), - }, - onClick: this.radioHandler.bind(this), - }), - ]), - - h('h3.text-transform-uppercase', { - style: { - paddingLeft: '15px', - fontFamily: 'Montserrat Light', - width: '100vw', - background: 'rgb(235, 235, 235)', - color: 'rgb(174, 174, 174)', - paddingTop: '4px', - paddingBottom: '4px', - }, - }, props.buyView.subview), - - this.formVersionSubview(), - ]) - - ) -} - -BuyButtonSubview.prototype.formVersionSubview = function () { - const network = this.props.network - if (network === '1') { - if (this.props.buyView.formView.coinbase) { - return h(CoinbaseForm, this.props) - } else if (this.props.buyView.formView.shapeshift) { - return h(ShapeshiftForm, this.props) - } - } -} - -BuyButtonSubview.prototype.navigateTo = function (url) { - global.platform.openWindow({ url }) -} - -BuyButtonSubview.prototype.backButtonContext = function () { - if (this.props.context === 'confTx') { - this.props.dispatch(actions.showConfTxPage({transForward: false})) - } else { - this.props.dispatch(actions.goHome()) - } -} - -BuyButtonSubview.prototype.radioHandler = function (event) { - switch (event.target.title) { - case 'Coinbase': - return this.props.dispatch(actions.coinBaseSubview()) - case 'ShapeShift': - return this.props.dispatch(actions.shapeShiftSubview(this.props.provider.type)) - } -} diff --git a/ui/app/components/card/card.component.js b/ui/app/components/card/card.component.js new file mode 100644 index 000000000..bb7241da1 --- /dev/null +++ b/ui/app/components/card/card.component.js @@ -0,0 +1,25 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +export default class Card extends PureComponent { + static propTypes = { + className: PropTypes.string, + overrideClassName: PropTypes.bool, + title: PropTypes.string, + children: PropTypes.node, + } + + render () { + const { className, overrideClassName, title } = this.props + + return ( + <div className={classnames({ 'card': !overrideClassName }, className)}> + <div className="card__title"> + { title } + </div> + { this.props.children } + </div> + ) + } +} diff --git a/ui/app/components/card/index.js b/ui/app/components/card/index.js new file mode 100644 index 000000000..c3ca6e3f4 --- /dev/null +++ b/ui/app/components/card/index.js @@ -0,0 +1 @@ +export { default } from './card.component' diff --git a/ui/app/components/card/index.scss b/ui/app/components/card/index.scss new file mode 100644 index 000000000..bde54a15e --- /dev/null +++ b/ui/app/components/card/index.scss @@ -0,0 +1,11 @@ +.card { + border-radius: 4px; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.08); + padding: 8px; + + &__title { + border-bottom: 1px solid #d8d8d8; + padding-bottom: 4px; + text-transform: capitalize; + } +} diff --git a/ui/app/components/card/tests/card.component.test.js b/ui/app/components/card/tests/card.component.test.js new file mode 100644 index 000000000..cea05033f --- /dev/null +++ b/ui/app/components/card/tests/card.component.test.js @@ -0,0 +1,25 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import Card from '../card.component' + +describe('Card Component', () => { + it('should render a card with a title and child element', () => { + const wrapper = shallow( + <Card + title="Test" + className="card-test-class" + > + <div className="child-test-class">Child</div> + </Card> + ) + + assert.ok(wrapper.hasClass('card-test-class')) + const title = wrapper.find('.card__title') + assert.ok(title) + assert.equal(title.text(), 'Test') + const child = wrapper.find('.child-test-class') + assert.ok(child) + assert.equal(child.text(), 'Child') + }) +}) 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 index 08923af88..74e95ece6 100644 --- 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 @@ -2,11 +2,8 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' import { Tabs, Tab } from '../../tabs' -import { - ConfirmPageContainerSummary, - ConfirmPageContainerError, - ConfirmPageContainerWarning, -} from './' +import { ConfirmPageContainerSummary, ConfirmPageContainerWarning } from './' +import ErrorMessage from '../../error-message' export default class ConfirmPageContainerContent extends Component { static propTypes = { @@ -18,6 +15,7 @@ export default class ConfirmPageContainerContent extends Component { hideSubtitle: PropTypes.bool, identiconAddress: PropTypes.string, nonce: PropTypes.string, + assetImage: PropTypes.string, subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), summaryComponent: PropTypes.node, title: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), @@ -60,6 +58,7 @@ export default class ConfirmPageContainerContent extends Component { hideSubtitle, identiconAddress, nonce, + assetImage, summaryComponent, detailsComponent, dataComponent, @@ -85,6 +84,7 @@ export default class ConfirmPageContainerContent extends Component { hideSubtitle={hideSubtitle} identiconAddress={identiconAddress} nonce={nonce} + assetImage={assetImage} /> ) } @@ -92,7 +92,7 @@ export default class ConfirmPageContainerContent extends Component { { (errorKey || errorMessage) && ( <div className="confirm-page-container-content__error-container"> - <ConfirmPageContainerError + <ErrorMessage errorMessage={errorMessage} errorKey={errorKey} /> 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 deleted file mode 100644 index 4ac95d0e3..000000000 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './confirm-page-container-error.component' 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 index 3b1ee62c5..38b158fd3 100644 --- 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 @@ -4,7 +4,7 @@ import classnames from 'classnames' import Identicon from '../../../identicon' const ConfirmPageContainerSummary = props => { - const { action, title, subtitle, hideSubtitle, className, identiconAddress, nonce } = props + const { action, title, subtitle, hideSubtitle, className, identiconAddress, nonce, assetImage } = props return ( <div className={classnames('confirm-page-container-summary', className)}> @@ -27,6 +27,7 @@ const ConfirmPageContainerSummary = props => { className="confirm-page-container-summary__identicon" diameter={36} address={identiconAddress} + image={assetImage} /> ) } @@ -51,6 +52,7 @@ ConfirmPageContainerSummary.propTypes = { className: PropTypes.string, identiconAddress: PropTypes.string, nonce: PropTypes.string, + assetImage: PropTypes.string, } export default ConfirmPageContainerSummary 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 index 1469dd438..4dfd89d92 100644 --- 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 @@ -1,4 +1,3 @@ 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 index 39797a43f..698e624f4 100644 --- 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 @@ -1,5 +1,3 @@ -@import './confirm-page-container-error/index'; - @import './confirm-page-container-warning/index'; @import './confirm-page-container-summary/index'; 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 index 24ff05353..36d5a1f58 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container.component.js +++ b/ui/app/components/confirm-page-container/confirm-page-container.component.js @@ -38,9 +38,12 @@ export default class ConfirmPageContainer extends Component { detailsComponent: PropTypes.node, identiconAddress: PropTypes.string, nonce: PropTypes.string, + assetImage: PropTypes.string, summaryComponent: PropTypes.node, warning: PropTypes.string, + unapprovedTxCount: PropTypes.number, // Footer + onCancelAll: PropTypes.func, onCancel: PropTypes.func, onSubmit: PropTypes.func, disabled: PropTypes.bool, @@ -66,12 +69,16 @@ export default class ConfirmPageContainer extends Component { summaryComponent, detailsComponent, dataComponent, + onCancelAll, onCancel, onSubmit, identiconAddress, nonce, + unapprovedTxCount, + assetImage, warning, } = this.props + const renderAssetImage = contentComponent || (!contentComponent && !identiconAddress) return ( <div className="page-container"> @@ -84,6 +91,7 @@ export default class ConfirmPageContainer extends Component { senderAddress={fromAddress} recipientName={toName} recipientAddress={toAddress} + assetImage={renderAssetImage ? assetImage : undefined} /> </ConfirmPageContainerHeader> { @@ -101,17 +109,25 @@ export default class ConfirmPageContainer extends Component { errorKey={errorKey} identiconAddress={identiconAddress} nonce={nonce} + assetImage={assetImage} warning={warning} /> ) } <PageContainerFooter onCancel={() => onCancel()} + cancelText={this.context.t('reject')} onSubmit={() => onSubmit()} submitText={this.context.t('confirm')} submitButtonType="confirm" disabled={disabled} - /> + > + {unapprovedTxCount > 1 && ( + <a onClick={() => onCancelAll()}> + {this.context.t('rejectTxsN', [unapprovedTxCount])} + </a> + )} + </PageContainerFooter> </div> ) } diff --git a/ui/app/components/currency-display/currency-display.component.js b/ui/app/components/currency-display/currency-display.component.js new file mode 100644 index 000000000..e4eb58a2a --- /dev/null +++ b/ui/app/components/currency-display/currency-display.component.js @@ -0,0 +1,31 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { ETH, GWEI } from '../../constants/common' + +export default class CurrencyDisplay extends PureComponent { + static propTypes = { + className: PropTypes.string, + displayValue: PropTypes.string, + prefix: PropTypes.string, + // Used in container + currency: PropTypes.oneOf([ETH]), + denomination: PropTypes.oneOf([GWEI]), + value: PropTypes.string, + numberOfDecimals: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + hideLabel: PropTypes.bool, + } + + render () { + const { className, displayValue, prefix } = this.props + const text = `${prefix || ''}${displayValue}` + + return ( + <div + className={className} + title={text} + > + { text } + </div> + ) + } +} diff --git a/ui/app/components/currency-display/currency-display.container.js b/ui/app/components/currency-display/currency-display.container.js new file mode 100644 index 000000000..6644a1099 --- /dev/null +++ b/ui/app/components/currency-display/currency-display.container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux' +import CurrencyDisplay from './currency-display.component' +import { getValueFromWeiHex, formatCurrency } from '../../helpers/confirm-transaction/util' + +const mapStateToProps = (state, ownProps) => { + const { value, numberOfDecimals = 2, currency, denomination, hideLabel } = ownProps + const { metamask: { currentCurrency, conversionRate } } = state + + const toCurrency = currency || currentCurrency + const convertedValue = getValueFromWeiHex({ + value, toCurrency, conversionRate, numberOfDecimals, toDenomination: denomination, + }) + const formattedValue = formatCurrency(convertedValue, toCurrency) + const displayValue = hideLabel ? formattedValue : `${formattedValue} ${toCurrency.toUpperCase()}` + + return { + displayValue, + } +} + +export default connect(mapStateToProps)(CurrencyDisplay) diff --git a/ui/app/components/currency-display/index.js b/ui/app/components/currency-display/index.js new file mode 100644 index 000000000..38f08765f --- /dev/null +++ b/ui/app/components/currency-display/index.js @@ -0,0 +1 @@ +export { default } from './currency-display.container' diff --git a/ui/app/components/currency-display/tests/currency-display.component.test.js b/ui/app/components/currency-display/tests/currency-display.component.test.js new file mode 100644 index 000000000..d9ef052f1 --- /dev/null +++ b/ui/app/components/currency-display/tests/currency-display.component.test.js @@ -0,0 +1,27 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import CurrencyDisplay from '../currency-display.component' + +describe('CurrencyDisplay Component', () => { + it('should render text with a className', () => { + const wrapper = shallow(<CurrencyDisplay + displayValue="$123.45" + className="currency-display" + />) + + assert.ok(wrapper.hasClass('currency-display')) + assert.equal(wrapper.text(), '$123.45') + }) + + it('should render text with a prefix', () => { + const wrapper = shallow(<CurrencyDisplay + displayValue="$123.45" + className="currency-display" + prefix="-" + />) + + assert.ok(wrapper.hasClass('currency-display')) + assert.equal(wrapper.text(), '-$123.45') + }) +}) diff --git a/ui/app/components/currency-display/tests/currency-display.container.test.js b/ui/app/components/currency-display/tests/currency-display.container.test.js new file mode 100644 index 000000000..5265bbb04 --- /dev/null +++ b/ui/app/components/currency-display/tests/currency-display.container.test.js @@ -0,0 +1,105 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' + +let mapStateToProps + +proxyquire('../currency-display.container.js', { + 'react-redux': { + connect: ms => { + mapStateToProps = ms + return () => ({}) + }, + }, +}) + +describe('CurrencyDisplay container', () => { + describe('mapStateToProps()', () => { + it('should return the correct props', () => { + const mockState = { + metamask: { + conversionRate: 280.45, + currentCurrency: 'usd', + }, + } + + const tests = [ + { + props: { + value: '0x2386f26fc10000', + numberOfDecimals: 2, + currency: 'usd', + }, + result: { + displayValue: '$2.80 USD', + }, + }, + { + props: { + value: '0x2386f26fc10000', + }, + result: { + displayValue: '$2.80 USD', + }, + }, + { + props: { + value: '0x1193461d01595930', + currency: 'ETH', + numberOfDecimals: 3, + }, + result: { + displayValue: '1.266 ETH', + }, + }, + { + props: { + value: '0x1193461d01595930', + currency: 'ETH', + numberOfDecimals: 3, + hideLabel: true, + }, + result: { + displayValue: '1.266', + }, + }, + { + props: { + value: '0x3b9aca00', + currency: 'ETH', + denomination: 'GWEI', + hideLabel: true, + }, + result: { + displayValue: '1', + }, + }, + { + props: { + value: '0x3b9aca00', + currency: 'ETH', + denomination: 'WEI', + hideLabel: true, + }, + result: { + displayValue: '1000000000', + }, + }, + { + props: { + value: '0x3b9aca00', + currency: 'ETH', + numberOfDecimals: 100, + hideLabel: true, + }, + result: { + displayValue: '1e-9', + }, + }, + ] + + tests.forEach(({ props, result }) => { + assert.deepEqual(mapStateToProps(mockState, props), result) + }) + }) + }) +}) diff --git a/ui/app/components/custom-radio-list.js b/ui/app/components/custom-radio-list.js deleted file mode 100644 index a4c525396..000000000 --- a/ui/app/components/custom-radio-list.js +++ /dev/null @@ -1,60 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const inherits = require('util').inherits - -module.exports = RadioList - -inherits(RadioList, Component) -function RadioList () { - Component.call(this) -} - -RadioList.prototype.render = function () { - const props = this.props - const activeClass = '.custom-radio-selected' - const inactiveClass = '.custom-radio-inactive' - const { - labels, - defaultFocus, - } = props - - - return ( - h('.flex-row', { - style: { - fontSize: '12px', - }, - }, [ - h('.flex-column.custom-radios', { - style: { - marginRight: '5px', - }, - }, - labels.map((lable, i) => { - let isSelcted = (this.state !== null) - isSelcted = isSelcted ? (this.state.selected === lable) : (defaultFocus === lable) - return h(isSelcted ? activeClass : inactiveClass, { - title: lable, - onClick: (event) => { - this.setState({selected: event.target.title}) - props.onClick(event) - }, - }) - }) - ), - h('.text', {}, - labels.map((lable) => { - if (props.subtext) { - return h('.flex-row', {}, [ - h('.radio-titles', lable), - h('.radio-titles-subtext', `- ${props.subtext[lable]}`), - ]) - } else { - return h('.radio-titles', lable) - } - }) - ), - ]) - ) -} - diff --git a/ui/app/components/customize-gas-modal/index.js b/ui/app/components/customize-gas-modal/index.js index c255fd64d..e67fbe45b 100644 --- a/ui/app/components/customize-gas-modal/index.js +++ b/ui/app/components/customize-gas-modal/index.js @@ -5,6 +5,7 @@ const inherits = require('util').inherits const connect = require('react-redux').connect const actions = require('../../actions') const GasModalCard = require('./gas-modal-card') +import Button from '../button' const ethUtil = require('ethereumjs-util') @@ -353,16 +354,16 @@ CustomizeGasModal.prototype.render = function () { }, [this.context.t('revert')]), h('div.send-v2__customize-gas__buttons', [ - h('button.btn-default.send-v2__customize-gas__cancel', { + h(Button, { + type: 'default', + className: 'send-v2__customize-gas__cancel', onClick: this.props.hideModal, - style: { - marginRight: '10px', - }, }, [this.context.t('cancel')]), - - h('button.btn-primary.send-v2__customize-gas__save', { + h(Button, { + type: 'primary', + className: 'send-v2__customize-gas__save', onClick: () => !error && this.save(newGasPrice, gasLimit, gasTotal), - className: error && 'btn-primary--disabled', + disabled: error, }, [this.context.t('save')]), ]), diff --git a/ui/app/components/dropdowns/account-details-dropdown.js b/ui/app/components/dropdowns/account-details-dropdown.js new file mode 100644 index 000000000..7476cfdd9 --- /dev/null +++ b/ui/app/components/dropdowns/account-details-dropdown.js @@ -0,0 +1,109 @@ +const Component = require('react').Component +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const inherits = require('util').inherits +const connect = require('react-redux').connect +const actions = require('../../actions') +const { getSelectedIdentity } = require('../../selectors') +const genAccountLink = require('../../../lib/account-link.js') +const { Menu, Item, CloseArea } = require('./components/menu') + +AccountDetailsDropdown.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountDetailsDropdown) + +function mapStateToProps (state) { + return { + selectedIdentity: getSelectedIdentity(state), + network: state.metamask.network, + keyrings: state.metamask.keyrings, + } +} + +function mapDispatchToProps (dispatch) { + return { + showAccountDetailModal: () => { + dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) + }, + viewOnEtherscan: (address, network) => { + global.platform.openWindow({ url: genAccountLink(address, network) }) + }, + showRemoveAccountConfirmationModal: (identity) => { + return dispatch(actions.showModal({ name: 'CONFIRM_REMOVE_ACCOUNT', identity })) + }, + } +} + +inherits(AccountDetailsDropdown, Component) +function AccountDetailsDropdown () { + Component.call(this) + + this.onClose = this.onClose.bind(this) +} + +AccountDetailsDropdown.prototype.onClose = function (e) { + e.stopPropagation() + this.props.onClose() +} + +AccountDetailsDropdown.prototype.render = function () { + const { + selectedIdentity, + network, + keyrings, + showAccountDetailModal, + viewOnEtherscan, + showRemoveAccountConfirmationModal } = this.props + + const address = selectedIdentity.address + + const keyring = keyrings.find((kr) => { + return kr.accounts.includes(address) + }) + + const isRemovable = keyring.type !== 'HD Key Tree' + + return h(Menu, { className: 'account-details-dropdown', isShowing: true }, [ + h(CloseArea, { + onClick: this.onClose, + }), + h(Item, { + onClick: (e) => { + e.stopPropagation() + global.platform.openExtensionInBrowser() + this.props.onClose() + }, + text: this.context.t('expandView'), + icon: h(`img`, { src: 'images/expand.svg', style: { height: '15px' } }), + }), + h(Item, { + onClick: (e) => { + e.stopPropagation() + showAccountDetailModal() + this.props.onClose() + }, + text: this.context.t('accountDetails'), + icon: h(`img`, { src: 'images/info.svg', style: { height: '15px' } }), + }), + h(Item, { + onClick: (e) => { + e.stopPropagation() + viewOnEtherscan(address, network) + this.props.onClose() + }, + text: this.context.t('viewOnEtherscan'), + icon: h(`img`, { src: 'images/open-etherscan.svg', style: { height: '15px' } }), + }), + isRemovable ? h(Item, { + onClick: (e) => { + e.stopPropagation() + showRemoveAccountConfirmationModal(selectedIdentity) + this.props.onClose() + }, + text: this.context.t('removeAccount'), + icon: h(`img`, { src: 'images/hide.svg', style: { height: '15px' } }), + }) : null, + ]) +} diff --git a/ui/app/components/dropdowns/components/account-dropdowns.js b/ui/app/components/dropdowns/components/account-dropdowns.js index 179b6617f..b497f5c09 100644 --- a/ui/app/components/dropdowns/components/account-dropdowns.js +++ b/ui/app/components/dropdowns/components/account-dropdowns.js @@ -459,7 +459,7 @@ const mapDispatchToProps = (dispatch) => { function mapStateToProps (state) { return { keyrings: state.metamask.keyrings, - sidebarOpen: state.appState.sidebarOpen, + sidebarOpen: state.appState.sidebar.isOpen, } } diff --git a/ui/app/components/dropdowns/network-dropdown.js b/ui/app/components/dropdowns/network-dropdown.js index e5363ff56..b252b25d9 100644 --- a/ui/app/components/dropdowns/network-dropdown.js +++ b/ui/app/components/dropdowns/network-dropdown.js @@ -43,6 +43,9 @@ function mapDispatchToProps (dispatch) { setRpcTarget: (target) => { dispatch(actions.setRpcTarget(target)) }, + delRpcTarget: (target) => { + dispatch(actions.delRpcTarget(target)) + }, showNetworkDropdown: () => dispatch(actions.showNetworkDropdown()), hideNetworkDropdown: () => dispatch(actions.hideNetworkDropdown()), } @@ -272,10 +275,12 @@ NetworkDropdown.prototype.getNetworkName = function () { NetworkDropdown.prototype.renderCommonRpc = function (rpcList, provider) { const props = this.props - const rpcTarget = provider.rpcTarget + const reversedRpcList = rpcList.slice().reverse() + + return reversedRpcList.map((rpc) => { + const currentRpcTarget = provider.type === 'rpc' && rpc === provider.rpcTarget - return rpcList.map((rpc) => { - if ((rpc === 'http://localhost:8545') || (rpc === rpcTarget)) { + if ((rpc === 'http://localhost:8545') || currentRpcTarget) { return null } else { return h( @@ -291,13 +296,20 @@ NetworkDropdown.prototype.renderCommonRpc = function (rpcList, provider) { }, }, [ - rpcTarget === rpc ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), + currentRpcTarget ? h('i.fa.fa-check') : h('.network-check__transparent', '✓'), h('i.fa.fa-question-circle.fa-med.menu-icon-circle'), h('span.network-name-item', { style: { - color: rpcTarget === rpc ? '#ffffff' : '#9b9b9b', + color: currentRpcTarget ? '#ffffff' : '#9b9b9b', }, }, rpc), + h('i.fa.fa-times.delete', + { + onClick: (e) => { + e.stopPropagation() + props.delRpcTarget(rpc) + }, + }), ] ) } diff --git a/ui/app/components/dropdowns/tests/dropdown.test.js b/ui/app/components/dropdowns/tests/dropdown.test.js new file mode 100644 index 000000000..2b026589a --- /dev/null +++ b/ui/app/components/dropdowns/tests/dropdown.test.js @@ -0,0 +1,37 @@ +import React from 'react' +import assert from 'assert' +import sinon from 'sinon' +import { shallow } from 'enzyme' +import { DropdownMenuItem } from '../components/dropdown.js' + +describe('', () => { + let wrapper + const onClickSpy = sinon.spy() + const closeMenuSpy = sinon.spy() + + beforeEach(() => { + wrapper = shallow( + <DropdownMenuItem + onClick = {onClickSpy} + style = {{test: 'style'}} + closeMenu = {closeMenuSpy} + > + </DropdownMenuItem> + ) + }) + + it('renders li with dropdown-menu-item class', () => { + assert.equal(wrapper.find('li.dropdown-menu-item').length, 1) + }) + + it('adds style based on props passed', () => { + assert.equal(wrapper.prop('style').test, 'style') + }) + + it('simulates click event and calls onClick and closeMenu', () => { + wrapper.prop('onClick')() + assert.equal(onClickSpy.callCount, 1) + assert.equal(closeMenuSpy.callCount, 1) + }) + +}) diff --git a/ui/app/components/dropdowns/tests/menu.test.js b/ui/app/components/dropdowns/tests/menu.test.js new file mode 100644 index 000000000..9f5f13f00 --- /dev/null +++ b/ui/app/components/dropdowns/tests/menu.test.js @@ -0,0 +1,87 @@ +import React from 'react' +import assert from 'assert' +import sinon from 'sinon' +import { shallow } from 'enzyme' +import { Menu, Item, Divider, CloseArea } from '../components/menu' + +describe('Dropdown Menu Components', () => { + + describe('Menu', () => { + let wrapper + + beforeEach(() => { + wrapper = shallow( + <Menu className = {'Test Class'} isShowing = {true}/> + ) + }) + + it('adds prop className to menu', () => { + assert.equal(wrapper.find('.menu').prop('className'), 'menu Test Class') + }) + + }) + + describe('Item', () => { + let wrapper + + const onClickSpy = sinon.spy() + + beforeEach(() => { + wrapper = shallow( + <Item + icon = {'test icon'} + text = {'test text'} + className = {'test className'} + onClick = {onClickSpy} + /> + ) + }) + + it('add className based on props', () => { + assert.equal(wrapper.find('.menu__item').prop('className'), 'menu__item menu__item test className menu__item--clickable') + }) + + it('simulates onClick called', () => { + wrapper.find('.menu__item').prop('onClick')() + assert.equal(onClickSpy.callCount, 1) + }) + + it('adds icon based on icon props', () => { + assert.equal(wrapper.find('.menu__item__icon').text(), 'test icon') + }) + + it('adds html text based on text props', () => { + assert.equal(wrapper.find('.menu__item__text').text(), 'test text') + }) + }) + + describe('Divider', () => { + let wrapper + + before(() => { + wrapper = shallow(<Divider />) + }) + + it('renders menu divider', () => { + assert.equal(wrapper.find('.menu__divider').length, 1) + }) + }) + + describe('CloseArea', () => { + let wrapper + + const onClickSpy = sinon.spy() + + beforeEach(() => { + wrapper = shallow(<CloseArea + onClick = {onClickSpy} + />) + }) + + it('simulates click', () => { + wrapper.prop('onClick')() + assert.equal(onClickSpy.callCount, 1) + }) + }) + +}) diff --git a/ui/app/components/dropdowns/tests/network-dropdown-icon.test.js b/ui/app/components/dropdowns/tests/network-dropdown-icon.test.js new file mode 100644 index 000000000..67b192c11 --- /dev/null +++ b/ui/app/components/dropdowns/tests/network-dropdown-icon.test.js @@ -0,0 +1,25 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import NetworkDropdownIcon from '../components/network-dropdown-icon' + +describe('Network Dropdown Icon', () => { + let wrapper + + beforeEach(() => { + wrapper = shallow(<NetworkDropdownIcon + backgroundColor = {'red'} + isSelected = {false} + innerBorder = {'none'} + diameter = {'12'} + />) + }) + + it('adds style props based on props', () => { + const styleProp = wrapper.find('.menu-icon-circle').children().prop('style') + assert.equal(styleProp.background, 'red') + assert.equal(styleProp.border, 'none') + assert.equal(styleProp.height, '12px') + assert.equal(styleProp.width, '12px') + }) +}) diff --git a/ui/app/components/dropdowns/tests/network-dropdown.test.js b/ui/app/components/dropdowns/tests/network-dropdown.test.js new file mode 100644 index 000000000..699b54605 --- /dev/null +++ b/ui/app/components/dropdowns/tests/network-dropdown.test.js @@ -0,0 +1,97 @@ +import React from 'react' +import assert from 'assert' +import { createMockStore } from 'redux-test-utils' +import { mountWithRouter } from '../../../../../test/lib/render-helpers' +import NetworkDropdown from '../network-dropdown' +import { DropdownMenuItem } from '../components/dropdown' +import NetworkDropdownIcon from '../components/network-dropdown-icon' + +describe('Network Dropdown', () => { + let wrapper + + describe('NetworkDropdown in appState in false', () => { + const mockState = { + metamask: { + provider: { + type: 'test', + }, + }, + appState: { + networkDropdown: false, + }, + } + + const store = createMockStore(mockState) + + beforeEach(() => { + wrapper = mountWithRouter( + <NetworkDropdown store={store} /> + ) + }) + + it('checks for network droppo class', () => { + assert.equal(wrapper.find('.network-droppo').length, 1) + }) + + it('renders only one child when networkDropdown is false in state', () => { + assert.equal(wrapper.children().length, 1) + }) + + }) + + describe('NetworkDropdown in appState is true', () => { + const mockState = { + metamask: { + provider: { + 'type': 'test', + }, + frequentRpcList: [ + 'http://localhost:7545', + ], + }, + appState: { + 'networkDropdownOpen': true, + }, + } + const store = createMockStore(mockState) + + beforeEach(() => { + wrapper = mountWithRouter( + <NetworkDropdown store={store}/>, + ) + }) + + it('renders 7 DropDownMenuItems ', () => { + assert.equal(wrapper.find(DropdownMenuItem).length, 7) + }) + + it('checks background color for first NetworkDropdownIcon', () => { + assert.equal(wrapper.find(NetworkDropdownIcon).at(0).prop('backgroundColor'), '#29B6AF') // Main Ethereum Network Teal + }) + + it('checks background color for second NetworkDropdownIcon', () => { + assert.equal(wrapper.find(NetworkDropdownIcon).at(1).prop('backgroundColor'), '#ff4a8d') // Ropsten Red + }) + + it('checks background color for third NetworkDropdownIcon', () => { + assert.equal(wrapper.find(NetworkDropdownIcon).at(2).prop('backgroundColor'), '#7057ff') // Kovan Purple + }) + + it('checks background color for fourth NetworkDropdownIcon', () => { + assert.equal(wrapper.find(NetworkDropdownIcon).at(3).prop('backgroundColor'), '#f6c343') // Rinkeby Yellow + }) + + it('checks background color for fifth NetworkDropdownIcon', () => { + assert.equal(wrapper.find(NetworkDropdownIcon).at(4).prop('innerBorder'), '1px solid #9b9b9b') + }) + + it('checks dropdown for frequestRPCList from state ', () => { + assert.equal(wrapper.find(DropdownMenuItem).at(5).text(), '✓http://localhost:7545') + }) + + it('checks background color for sixth NetworkDropdownIcon', () => { + assert.equal(wrapper.find(NetworkDropdownIcon).at(5).prop('innerBorder'), '1px solid #9b9b9b') + }) + + }) +}) diff --git a/ui/app/components/dropdowns/token-menu-dropdown.js b/ui/app/components/dropdowns/token-menu-dropdown.js index 5a794c7c1..8a072b1bc 100644 --- a/ui/app/components/dropdowns/token-menu-dropdown.js +++ b/ui/app/components/dropdowns/token-menu-dropdown.js @@ -5,7 +5,6 @@ const inherits = require('util').inherits const connect = require('react-redux').connect const actions = require('../../actions') const genAccountLink = require('etherscan-link').createAccountLink -const copyToClipboard = require('copy-to-clipboard') const { Menu, Item, CloseArea } = require('./components/menu') TokenMenuDropdown.contextTypes = { @@ -59,14 +58,6 @@ TokenMenuDropdown.prototype.render = function () { h(Item, { onClick: (e) => { e.stopPropagation() - copyToClipboard(this.props.token.address) - this.props.onClose() - }, - text: this.context.t('copyContractAddress'), - }), - h(Item, { - onClick: (e) => { - e.stopPropagation() const url = genAccountLink(this.props.token.address, this.props.network) global.platform.openWindow({ url }) this.props.onClose() 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/error-message/error-message.component.js index 70ebdeb20..b4464c33b 100644 --- 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/error-message/error-message.component.js @@ -1,28 +1,30 @@ import React from 'react' import PropTypes from 'prop-types' -const ConfirmPageContainerError = (props, context) => { +const ErrorMessage = (props, context) => { const { errorMessage, errorKey } = props const error = errorKey ? context.t(errorKey) : errorMessage return ( - <div className="confirm-page-container-error"> + <div className="error-message"> <img src="/images/alert-red.svg" - className="confirm-page-container-error__icon" + className="error-message__icon" /> - { `ALERT: ${error}` } + <div className="error-message__text"> + { `ALERT: ${error}` } + </div> </div> ) } -ConfirmPageContainerError.propTypes = { +ErrorMessage.propTypes = { errorMessage: PropTypes.string, errorKey: PropTypes.string, } -ConfirmPageContainerError.contextTypes = { +ErrorMessage.contextTypes = { t: PropTypes.func, } -export default ConfirmPageContainerError +export default ErrorMessage diff --git a/ui/app/components/error-message/index.js b/ui/app/components/error-message/index.js new file mode 100644 index 000000000..1c97a9955 --- /dev/null +++ b/ui/app/components/error-message/index.js @@ -0,0 +1 @@ +export { default } from './error-message.component' diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.scss b/ui/app/components/error-message/index.scss index e99b0f631..5915e21cf 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.scss +++ b/ui/app/components/error-message/index.scss @@ -1,5 +1,5 @@ -.confirm-page-container-error { - height: 32px; +.error-message { + min-height: 32px; border: 1px solid $monzo; color: $monzo; background: lighten($monzo, 56%); @@ -8,10 +8,14 @@ display: flex; justify-content: flex-start; align-items: center; - padding-left: 16px; + padding: 8px 16px; &__icon { margin-right: 8px; flex: 0 0 auto; } + + &__text { + overflow: auto; + } } diff --git a/ui/app/components/error-message/tests/error-message.component.test.js b/ui/app/components/error-message/tests/error-message.component.test.js new file mode 100644 index 000000000..8c5347173 --- /dev/null +++ b/ui/app/components/error-message/tests/error-message.component.test.js @@ -0,0 +1,36 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import ErrorMessage from '../error-message.component' + +describe('ErrorMessage Component', () => { + const t = key => `translate ${key}` + + it('should render a message from props.errorMessage', () => { + const wrapper = shallow( + <ErrorMessage + errorMessage="This is an error." + />, + { context: { t }} + ) + + assert.ok(wrapper) + assert.equal(wrapper.find('.error-message').length, 1) + assert.equal(wrapper.find('.error-message__icon').length, 1) + assert.equal(wrapper.find('.error-message__text').text(), 'ALERT: This is an error.') + }) + + it('should render a message translated from props.errorKey', () => { + const wrapper = shallow( + <ErrorMessage + errorKey="testKey" + />, + { context: { t }} + ) + + assert.ok(wrapper) + assert.equal(wrapper.find('.error-message').length, 1) + assert.equal(wrapper.find('.error-message__icon').length, 1) + assert.equal(wrapper.find('.error-message__text').text(), 'ALERT: translate testKey') + }) +}) diff --git a/ui/app/components/hex-as-decimal-input.js b/ui/app/components/hex-as-decimal-input.js deleted file mode 100644 index 75303a34a..000000000 --- a/ui/app/components/hex-as-decimal-input.js +++ /dev/null @@ -1,161 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const inherits = require('util').inherits -const ethUtil = require('ethereumjs-util') -const BN = ethUtil.BN -const extend = require('xtend') -const connect = require('react-redux').connect - -HexAsDecimalInput.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect()(HexAsDecimalInput) - - -inherits(HexAsDecimalInput, Component) -function HexAsDecimalInput () { - this.state = { invalid: null } - Component.call(this) -} - -/* Hex as Decimal Input - * - * A component for allowing easy, decimal editing - * of a passed in hex string value. - * - * On change, calls back its `onChange` function parameter - * and passes it an updated hex string. - */ - -HexAsDecimalInput.prototype.render = function () { - const props = this.props - const state = this.state - - const { value, onChange, min, max } = props - - const toEth = props.toEth - const suffix = props.suffix - const decimalValue = decimalize(value, toEth) - const style = props.style - - return ( - h('.flex-column', [ - h('.flex-row', { - style: { - alignItems: 'flex-end', - lineHeight: '13px', - fontFamily: 'Montserrat Light', - textRendering: 'geometricPrecision', - }, - }, [ - h('input.hex-input', { - type: 'number', - required: true, - min: min, - max: max, - style: extend({ - display: 'block', - textAlign: 'right', - backgroundColor: 'transparent', - border: '1px solid #bdbdbd', - - }, style), - value: parseInt(decimalValue), - onBlur: (event) => { - this.updateValidity(event) - }, - onChange: (event) => { - this.updateValidity(event) - const hexString = (event.target.value === '') ? '' : hexify(event.target.value) - onChange(hexString) - }, - onInvalid: (event) => { - const msg = this.constructWarning() - if (msg === state.invalid) { - return - } - this.setState({ invalid: msg }) - event.preventDefault() - return false - }, - }), - h('div', { - style: { - color: ' #AEAEAE', - fontSize: '12px', - marginLeft: '5px', - marginRight: '6px', - width: '20px', - }, - }, suffix), - ]), - - state.invalid ? h('span.error', { - style: { - position: 'absolute', - right: '0px', - textAlign: 'right', - transform: 'translateY(26px)', - padding: '3px', - background: 'rgba(255,255,255,0.85)', - zIndex: '1', - textTransform: 'capitalize', - border: '2px solid #E20202', - }, - }, state.invalid) : null, - ]) - ) -} - -HexAsDecimalInput.prototype.setValid = function (message) { - this.setState({ invalid: null }) -} - -HexAsDecimalInput.prototype.updateValidity = function (event) { - const target = event.target - const value = this.props.value - const newValue = target.value - - if (value === newValue) { - return - } - - const valid = target.checkValidity() - if (valid) { - this.setState({ invalid: null }) - } -} - -HexAsDecimalInput.prototype.constructWarning = function () { - const { name, min, max } = this.props - let message = name ? name + ' ' : '' - - if (min && max) { - message += this.context.t('betweenMinAndMax', [min, max]) - } else if (min) { - message += this.context.t('greaterThanMin', [min]) - } else if (max) { - message += this.context.t('lessThanMax', [max]) - } else { - message += this.context.t('invalidInput') - } - - return message -} - -function hexify (decimalString) { - const hexBN = new BN(parseInt(decimalString), 10) - return '0x' + hexBN.toString('hex') -} - -function decimalize (input, toEth) { - if (input === '') { - return '' - } else { - const strippedInput = ethUtil.stripHexPrefix(input) - const inputBN = new BN(strippedInput, 'hex') - return inputBN.toString(10) - } -} diff --git a/ui/app/components/hex-to-decimal/hex-to-decimal.component.js b/ui/app/components/hex-to-decimal/hex-to-decimal.component.js new file mode 100644 index 000000000..6847a6919 --- /dev/null +++ b/ui/app/components/hex-to-decimal/hex-to-decimal.component.js @@ -0,0 +1,21 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { hexToDecimal } from '../../helpers/conversions.util' + +export default class HexToDecimal extends PureComponent { + static propTypes = { + className: PropTypes.string, + value: PropTypes.string, + } + + render () { + const { className, value } = this.props + const decimalValue = hexToDecimal(value) + + return ( + <div className={className}> + { decimalValue } + </div> + ) + } +} diff --git a/ui/app/components/hex-to-decimal/index.js b/ui/app/components/hex-to-decimal/index.js new file mode 100644 index 000000000..6e8567ca9 --- /dev/null +++ b/ui/app/components/hex-to-decimal/index.js @@ -0,0 +1 @@ +export { default } from './hex-to-decimal.component' diff --git a/ui/app/components/hex-to-decimal/tests/hex-to-decimal.component.test.js b/ui/app/components/hex-to-decimal/tests/hex-to-decimal.component.test.js new file mode 100644 index 000000000..c98da9ad4 --- /dev/null +++ b/ui/app/components/hex-to-decimal/tests/hex-to-decimal.component.test.js @@ -0,0 +1,26 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import HexToDecimal from '../hex-to-decimal.component' + +describe('HexToDecimal Component', () => { + it('should render a prefixed hex as a decimal with a className', () => { + const wrapper = shallow(<HexToDecimal + value="0x3039" + className="hex-to-decimal" + />) + + assert.ok(wrapper.hasClass('hex-to-decimal')) + assert.equal(wrapper.text(), '12345') + }) + + it('should render an unprefixed hex as a decimal with a className', () => { + const wrapper = shallow(<HexToDecimal + value="1A85" + className="hex-to-decimal" + />) + + assert.ok(wrapper.hasClass('hex-to-decimal')) + assert.equal(wrapper.text(), '6789') + }) +}) diff --git a/ui/app/components/identicon.js b/ui/app/components/identicon.js index 424048745..7bd921892 100644 --- a/ui/app/components/identicon.js +++ b/ui/app/components/identicon.js @@ -26,36 +26,43 @@ function mapStateToProps (state) { IdenticonComponent.prototype.render = function () { var props = this.props - const { className = '', address } = props + const { className = '', address, image } = props var diameter = props.diameter || this.defaultDiameter - - return address - ? ( - h('div', { - className: `${className} identicon`, - key: 'identicon-' + address, - style: { - display: 'flex', - flexShrink: 0, - alignItems: 'center', - justifyContent: 'center', - height: diameter, - width: diameter, - borderRadius: diameter / 2, - overflow: 'hidden', - }, - }) - ) - : ( - h('img.balance-icon', { - src: './images/eth_logo.svg', - style: { - height: diameter, - width: diameter, - borderRadius: diameter / 2, - }, - }) - ) + const style = { + height: diameter, + width: diameter, + borderRadius: diameter / 2, + } + if (image) { + return h('img', { + className: `${className} identicon`, + src: image, + style: { + ...style, + }, + }) + } else if (address) { + return h('div', { + className: `${className} identicon`, + key: 'identicon-' + address, + style: { + display: 'flex', + flexShrink: 0, + alignItems: 'center', + justifyContent: 'center', + ...style, + overflow: 'hidden', + }, + }) + } else { + return h('img.balance-icon', { + className, + src: './images/eth_logo.svg', + style: { + ...style, + }, + }) + } } IdenticonComponent.prototype.componentDidMount = function () { diff --git a/ui/app/components/index.scss b/ui/app/components/index.scss index b3e14ce23..21b65bf55 100644 --- a/ui/app/components/index.scss +++ b/ui/app/components/index.scss @@ -1,21 +1,51 @@ +@import './app-header/index'; + @import './button-group/index'; -@import './export-text-container/index'; +@import './card/index'; -@import './selected-account/index'; +@import './confirm-page-container/index'; + +@import './error-message/index'; + +@import './export-text-container/index'; @import './info-box/index'; -@import './network-display/index'; +@import './menu-bar/index'; -@import './confirm-page-container/index'; +@import './modal/index'; + +@import './modals/index'; + +@import './network-display/index'; @import './page-container/index'; @import './pages/index'; -@import './modals/index'; +@import './selected-account/index'; @import './sender-to-recipient/index'; @import './tabs/index'; + +@import './transaction-activity-log/index'; + +@import './transaction-breakdown/index'; + +@import './transaction-view/index'; + +@import './transaction-view-balance/index'; + +@import './transaction-list/index'; + +@import './transaction-list-item/index'; + +@import './transaction-list-item-details/index'; + +@import './transaction-status/index'; + +@import './app-header/index'; + +@import './sidebars/index'; diff --git a/ui/app/components/input-number.js b/ui/app/components/input-number.js index 59c6842ef..eec5e3740 100644 --- a/ui/app/components/input-number.js +++ b/ui/app/components/input-number.js @@ -66,13 +66,16 @@ InputNumber.prototype.render = function () { }), h('span.gas-tooltip-input-detail', {}, [unitLabel]), h('div.gas-tooltip-input-arrows', {}, [ - h('i.fa.fa-angle-up', { + h('div.gas-tooltip-input-arrow-wrapper', { onClick: () => this.setValue(addCurrencies(value, step, { toNumericBase: 'dec' })), - }), - h('i.fa.fa-angle-down', { - style: { cursor: 'pointer' }, + }, [ + h('i.fa.fa-angle-up'), + ]), + h('div.gas-tooltip-input-arrow-wrapper', { onClick: () => this.setValue(subtractCurrencies(value, step, { toNumericBase: 'dec' })), - }), + }, [ + h('i.fa.fa-angle-down'), + ]), ]), ]) } diff --git a/ui/app/components/menu-bar/index.js b/ui/app/components/menu-bar/index.js new file mode 100644 index 000000000..c5760847f --- /dev/null +++ b/ui/app/components/menu-bar/index.js @@ -0,0 +1 @@ +export { default } from './menu-bar.container' diff --git a/ui/app/components/menu-bar/index.scss b/ui/app/components/menu-bar/index.scss new file mode 100644 index 000000000..f699f4090 --- /dev/null +++ b/ui/app/components/menu-bar/index.scss @@ -0,0 +1,23 @@ +.menu-bar { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + flex: 0 0 auto; + margin-bottom: 16px; + padding: 5px; + border-bottom: 1px solid #e5e5e5; + + &__sidebar-button { + font-size: 1.25rem; + cursor: pointer; + padding: 10px; + } + + &__open-in-browser { + cursor: pointer; + display: flex; + justify-content: center; + padding: 10px; + } +} diff --git a/ui/app/components/menu-bar/menu-bar.component.js b/ui/app/components/menu-bar/menu-bar.component.js new file mode 100644 index 000000000..7460e8dd5 --- /dev/null +++ b/ui/app/components/menu-bar/menu-bar.component.js @@ -0,0 +1,63 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Tooltip from '../tooltip' +import SelectedAccount from '../selected-account' +import AccountDetailsDropdown from '../dropdowns/account-details-dropdown.js' + +export default class MenuBar extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + hideSidebar: PropTypes.func, + isMascara: PropTypes.bool, + sidebarOpen: PropTypes.bool, + showSidebar: PropTypes.func, + } + + state = { accountDetailsMenuOpen: false } + + render () { + const { t } = this.context + const { isMascara, sidebarOpen, hideSidebar, showSidebar } = this.props + const { accountDetailsMenuOpen } = this.state + + return ( + <div className="menu-bar"> + <Tooltip + title={t('menu')} + position="bottom" + > + <div + className="fa fa-bars menu-bar__sidebar-button" + onClick={() => sidebarOpen ? hideSidebar() : showSidebar()} + /> + </Tooltip> + <SelectedAccount /> + { + !isMascara && ( + <Tooltip + title={t('accountOptions')} + position="bottom" + > + <div + className="fa fa-ellipsis-h fa-lg menu-bar__open-in-browser" + onClick={() => this.setState({ accountDetailsMenuOpen: true })} + > + </div> + </Tooltip> + ) + } + { + accountDetailsMenuOpen && ( + <AccountDetailsDropdown + className="menu-bar__account-details-dropdown" + onClose={() => this.setState({ accountDetailsMenuOpen: false })} + /> + ) + } + </div> + ) + } +} diff --git a/ui/app/components/menu-bar/menu-bar.container.js b/ui/app/components/menu-bar/menu-bar.container.js new file mode 100644 index 000000000..ae32882ae --- /dev/null +++ b/ui/app/components/menu-bar/menu-bar.container.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux' +import MenuBar from './menu-bar.component' +import { showSidebar, hideSidebar } from '../../actions' + +const mapStateToProps = state => { + const { appState: { sidebar: { isOpen }, isMascara } } = state + + return { + sidebarOpen: isOpen, + isMascara, + } +} + +const mapDispatchToProps = dispatch => { + return { + showSidebar: () => { + dispatch(showSidebar({ + transitionName: 'sidebar-right', + type: 'wallet-view', + })) + }, + hideSidebar: () => dispatch(hideSidebar()), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(MenuBar) diff --git a/ui/app/components/modal/index.js b/ui/app/components/modal/index.js new file mode 100644 index 000000000..58309abbe --- /dev/null +++ b/ui/app/components/modal/index.js @@ -0,0 +1,2 @@ +export { default } from './modal.component' +export { default as ModalContent } from './modal-content' diff --git a/ui/app/components/modal/index.scss b/ui/app/components/modal/index.scss new file mode 100644 index 000000000..2beb14633 --- /dev/null +++ b/ui/app/components/modal/index.scss @@ -0,0 +1,62 @@ +@import './modal-content/index'; + +.modal-container { + width: 100%; + height: 100%; + background-color: #fff; + display: flex; + flex-flow: column; + border-radius: 8px; + + @media screen and (max-width: 575px) { + max-height: 450px; + } + + &__content { + overflow-y: auto; + flex: 1; + padding: 16px 32px; + + @media screen and (max-width: 575px) { + justify-content: center; + padding: 28px 20px; + } + } + + &__header { + position: relative; + display: flex; + padding: 12px; + justify-content: center; + border-bottom: 1px solid #d2d8dd; + flex: 0 0 auto; + } + + &__header-close::after { + content: '\00D7'; + font-size: 40px; + color: $dusty-gray; + position: absolute; + top: -5px; + right: 10px; + cursor: pointer; + } + + &__footer { + display: flex; + flex-flow: row; + justify-content: center; + border-top: 1px solid #d2d8dd; + padding: 16px; + flex: 0 0 auto; + + &-button { + min-width: 0; + margin-right: 16px; + + &:last-of-type { + margin-right: 0; + } + } + } +} diff --git a/ui/app/components/modal/modal-content/index.js b/ui/app/components/modal/modal-content/index.js new file mode 100644 index 000000000..733cfb3b8 --- /dev/null +++ b/ui/app/components/modal/modal-content/index.js @@ -0,0 +1 @@ +export { default } from './modal-content.component' diff --git a/ui/app/components/modal/modal-content/index.scss b/ui/app/components/modal/modal-content/index.scss new file mode 100644 index 000000000..560505b84 --- /dev/null +++ b/ui/app/components/modal/modal-content/index.scss @@ -0,0 +1,19 @@ +.modal-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 0; + + &__title { + font-size: 1.5rem; + font-weight: 500; + padding: 16px 0; + text-align: center; + } + + &__description { + text-align: center; + font-size: .875rem; + } +} diff --git a/ui/app/components/modal/modal-content/modal-content.component.js b/ui/app/components/modal/modal-content/modal-content.component.js new file mode 100644 index 000000000..ecec0ee5b --- /dev/null +++ b/ui/app/components/modal/modal-content/modal-content.component.js @@ -0,0 +1,32 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' + +export default class ModalContent extends PureComponent { + static propTypes = { + title: PropTypes.string, + description: PropTypes.string, + } + + render () { + const { title, description } = this.props + + return ( + <div className="modal-content"> + { + title && ( + <div className="modal-content__title"> + { title } + </div> + ) + } + { + description && ( + <div className="modal-content__description"> + { description } + </div> + ) + } + </div> + ) + } +} diff --git a/ui/app/components/modal/modal-content/tests/modal-content.component.test.js b/ui/app/components/modal/modal-content/tests/modal-content.component.test.js new file mode 100644 index 000000000..17af09f45 --- /dev/null +++ b/ui/app/components/modal/modal-content/tests/modal-content.component.test.js @@ -0,0 +1,44 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import ModalContent from '../modal-content.component' + +describe('ModalContent Component', () => { + it('should render a title', () => { + const wrapper = shallow( + <ModalContent + title="Modal Title" + /> + ) + + assert.equal(wrapper.find('.modal-content__title').length, 1) + assert.equal(wrapper.find('.modal-content__title').text(), 'Modal Title') + assert.equal(wrapper.find('.modal-content__description').length, 0) + }) + + it('should render a description', () => { + const wrapper = shallow( + <ModalContent + description="Modal Description" + /> + ) + + assert.equal(wrapper.find('.modal-content__title').length, 0) + assert.equal(wrapper.find('.modal-content__description').length, 1) + assert.equal(wrapper.find('.modal-content__description').text(), 'Modal Description') + }) + + it('should render both a title and a description', () => { + const wrapper = shallow( + <ModalContent + title="Modal Title" + description="Modal Description" + /> + ) + + assert.equal(wrapper.find('.modal-content__title').length, 1) + assert.equal(wrapper.find('.modal-content__title').text(), 'Modal Title') + assert.equal(wrapper.find('.modal-content__description').length, 1) + assert.equal(wrapper.find('.modal-content__description').text(), 'Modal Description') + }) +}) diff --git a/ui/app/components/modal/modal.component.js b/ui/app/components/modal/modal.component.js new file mode 100644 index 000000000..2a75b559b --- /dev/null +++ b/ui/app/components/modal/modal.component.js @@ -0,0 +1,80 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Button from '../button' + +export default class Modal extends PureComponent { + static propTypes = { + children: PropTypes.node, + // Header text + headerText: PropTypes.string, + onClose: PropTypes.func, + // Submit button (right button) + onSubmit: PropTypes.func, + submitType: PropTypes.string, + submitText: PropTypes.string, + // Cancel button (left button) + onCancel: PropTypes.func, + cancelType: PropTypes.string, + cancelText: PropTypes.string, + } + + static defaultProps = { + submitType: 'primary', + cancelType: 'default', + } + + render () { + const { + children, + headerText, + onClose, + onSubmit, + submitType, + submitText, + onCancel, + cancelType, + cancelText, + } = this.props + + return ( + <div className="modal-container"> + { + headerText && ( + <div className="modal-container__header"> + <div className="modal-container__header-text"> + { headerText } + </div> + <div + className="modal-container__header-close" + onClick={onClose} + /> + </div> + ) + } + <div className="modal-container__content"> + { children } + </div> + <div className="modal-container__footer"> + { + onCancel && ( + <Button + type={cancelType} + onClick={onCancel} + className="modal-container__footer-button" + > + { cancelText } + </Button> + ) + } + <Button + type={submitType} + onClick={onSubmit} + className="modal-container__footer-button" + > + { submitText } + </Button> + </div> + </div> + ) + } +} diff --git a/ui/app/components/modal/tests/modal.component.test.js b/ui/app/components/modal/tests/modal.component.test.js new file mode 100644 index 000000000..8cce1a808 --- /dev/null +++ b/ui/app/components/modal/tests/modal.component.test.js @@ -0,0 +1,103 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import sinon from 'sinon' +import Modal from '../modal.component' +import Button from '../../button' + +describe('Modal Component', () => { + it('should render a modal with a submit button', () => { + const wrapper = shallow(<Modal />) + + assert.equal(wrapper.find('.modal-container').length, 1) + const buttons = wrapper.find(Button) + assert.equal(buttons.length, 1) + assert.equal(buttons.at(0).props().type, 'primary') + }) + + it('should render a modal with a cancel and a submit button', () => { + const handleCancel = sinon.spy() + const handleSubmit = sinon.spy() + const wrapper = shallow( + <Modal + onCancel={handleCancel} + cancelText="Cancel" + onSubmit={handleSubmit} + submitText="Submit" + /> + ) + + const buttons = wrapper.find(Button) + assert.equal(buttons.length, 2) + const cancelButton = buttons.at(0) + const submitButton = buttons.at(1) + + assert.equal(cancelButton.props().type, 'default') + assert.equal(cancelButton.props().children, 'Cancel') + assert.equal(handleCancel.callCount, 0) + cancelButton.simulate('click') + assert.equal(handleCancel.callCount, 1) + + assert.equal(submitButton.props().type, 'primary') + assert.equal(submitButton.props().children, 'Submit') + assert.equal(handleSubmit.callCount, 0) + submitButton.simulate('click') + assert.equal(handleSubmit.callCount, 1) + }) + + it('should render a modal with different button types', () => { + const wrapper = shallow( + <Modal + onCancel={() => {}} + cancelText="Cancel" + cancelType="secondary" + onSubmit={() => {}} + submitText="Submit" + submitType="confirm" + /> + ) + + const buttons = wrapper.find(Button) + assert.equal(buttons.length, 2) + assert.equal(buttons.at(0).props().type, 'secondary') + assert.equal(buttons.at(1).props().type, 'confirm') + }) + + it('should render a modal with children', () => { + const wrapper = shallow( + <Modal + onCancel={() => {}} + cancelText="Cancel" + onSubmit={() => {}} + submitText="Submit" + > + <div className="test-child" /> + </Modal> + ) + + assert.ok(wrapper.find('.test-class')) + }) + + it('should render a modal with a header', () => { + const handleCancel = sinon.spy() + const handleSubmit = sinon.spy() + const wrapper = shallow( + <Modal + onCancel={handleCancel} + cancelText="Cancel" + onSubmit={handleSubmit} + submitText="Submit" + headerText="My Header" + onClose={handleCancel} + /> + ) + + assert.ok(wrapper.find('.modal-container__header')) + assert.equal(wrapper.find('.modal-container__header-text').text(), 'My Header') + assert.equal(handleCancel.callCount, 0) + assert.equal(handleSubmit.callCount, 0) + wrapper.find('.modal-container__header-close').simulate('click') + assert.equal(handleCancel.callCount, 1) + assert.equal(handleSubmit.callCount, 0) + }) +}) diff --git a/ui/app/components/modals/account-details-modal.js b/ui/app/components/modals/account-details-modal.js index cc90cf578..248ffe008 100644 --- a/ui/app/components/modals/account-details-modal.js +++ b/ui/app/components/modals/account-details-modal.js @@ -10,6 +10,8 @@ const genAccountLink = require('../../../lib/account-link.js') const QrView = require('../qr-code') const EditableLabel = require('../editable-label') +import Button from '../button' + function mapStateToProps (state) { return { network: state.metamask.network, @@ -61,7 +63,7 @@ AccountDetailsModal.prototype.render = function () { let exportPrivateKeyFeatureEnabled = true // This feature is disabled for hardware wallets - if (keyring.type.search('Hardware') !== -1) { + if (keyring && keyring.type.search('Hardware') !== -1) { exportPrivateKeyFeatureEnabled = false } @@ -80,12 +82,17 @@ AccountDetailsModal.prototype.render = function () { h('div.account-modal-divider'), - h('button.btn-primary.account-modal__button', { + h(Button, { + type: 'primary', + className: 'account-modal__button', onClick: () => global.platform.openWindow({ url: genAccountLink(address, network) }), }, this.context.t('etherscanView')), // Holding on redesign for Export Private Key functionality - exportPrivateKeyFeatureEnabled ? h('button.btn-primary.account-modal__button', { + + exportPrivateKeyFeatureEnabled ? h(Button, { + type: 'primary', + className: 'account-modal__button', onClick: () => showExportPrivateKeyModal(), }, this.context.t('exportPrivateKey')) : null, diff --git a/ui/app/components/modals/account-modal-container.js b/ui/app/components/modals/account-modal-container.js index a9856b20f..aa0593df8 100644 --- a/ui/app/components/modals/account-modal-container.js +++ b/ui/app/components/modals/account-modal-container.js @@ -7,9 +7,9 @@ const actions = require('../../actions') const { getSelectedIdentity } = require('../../selectors') const Identicon = require('../identicon') -function mapStateToProps (state) { +function mapStateToProps (state, ownProps) { return { - selectedIdentity: getSelectedIdentity(state), + selectedIdentity: ownProps.selectedIdentity || getSelectedIdentity(state), } } diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/cancel-transaction-gas-fee.component.js b/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/cancel-transaction-gas-fee.component.js new file mode 100644 index 000000000..b082db1d0 --- /dev/null +++ b/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/cancel-transaction-gas-fee.component.js @@ -0,0 +1,29 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import CurrencyDisplay from '../../../currency-display' +import { ETH } from '../../../../constants/common' + +export default class CancelTransaction extends PureComponent { + static propTypes = { + value: PropTypes.string, + } + + render () { + const { value } = this.props + + return ( + <div className="cancel-transaction-gas-fee"> + <CurrencyDisplay + className="cancel-transaction-gas-fee__eth" + currency={ETH} + value={value} + numberOfDecimals={6} + /> + <CurrencyDisplay + className="cancel-transaction-gas-fee__fiat" + value={value} + /> + </div> + ) + } +} diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/index.js b/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/index.js new file mode 100644 index 000000000..1a9ae2e07 --- /dev/null +++ b/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/index.js @@ -0,0 +1 @@ +export { default } from './cancel-transaction-gas-fee.component' diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/index.scss b/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/index.scss new file mode 100644 index 000000000..ce81dd448 --- /dev/null +++ b/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/index.scss @@ -0,0 +1,17 @@ +.cancel-transaction-gas-fee { + background: #F1F4F9; + padding: 16px; + display: flex; + flex-direction: column; + align-items: center; + padding: 12px; + + &__eth { + font-size: 1.5rem; + font-weight: 500; + } + + &__fiat { + font-size: .75rem; + } +} diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/tests/cancel-transaction-gas-fee.component.test.js b/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/tests/cancel-transaction-gas-fee.component.test.js new file mode 100644 index 000000000..994c2a577 --- /dev/null +++ b/ui/app/components/modals/cancel-transaction/cancel-transaction-gas-fee/tests/cancel-transaction-gas-fee.component.test.js @@ -0,0 +1,27 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import CancelTransactionGasFee from '../cancel-transaction-gas-fee.component' +import CurrencyDisplay from '../../../../currency-display' + +describe('CancelTransactionGasFee Component', () => { + it('should render', () => { + const wrapper = shallow( + <CancelTransactionGasFee + value="0x3b9aca00" + /> + ) + + assert.ok(wrapper) + assert.equal(wrapper.find(CurrencyDisplay).length, 2) + const ethDisplay = wrapper.find(CurrencyDisplay).at(0) + const fiatDisplay = wrapper.find(CurrencyDisplay).at(1) + + assert.equal(ethDisplay.props().value, '0x3b9aca00') + assert.equal(ethDisplay.props().currency, 'ETH') + assert.equal(ethDisplay.props().className, 'cancel-transaction-gas-fee__eth') + + assert.equal(fiatDisplay.props().value, '0x3b9aca00') + assert.equal(fiatDisplay.props().className, 'cancel-transaction-gas-fee__fiat') + }) +}) diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction.component.js b/ui/app/components/modals/cancel-transaction/cancel-transaction.component.js new file mode 100644 index 000000000..8b00cb9b9 --- /dev/null +++ b/ui/app/components/modals/cancel-transaction/cancel-transaction.component.js @@ -0,0 +1,68 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Modal from '../../modal' +import CancelTransactionGasFee from './cancel-transaction-gas-fee' +import { SUBMITTED_STATUS } from '../../../constants/transactions' + +export default class CancelTransaction extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + createCancelTransaction: PropTypes.func, + hideModal: PropTypes.func, + showTransactionConfirmedModal: PropTypes.func, + transactionStatus: PropTypes.string, + newGasFee: PropTypes.string, + } + + componentDidUpdate () { + const { transactionStatus, showTransactionConfirmedModal } = this.props + + if (transactionStatus !== SUBMITTED_STATUS) { + showTransactionConfirmedModal() + return + } + } + + handleSubmit = async () => { + const { createCancelTransaction, hideModal } = this.props + + await createCancelTransaction() + hideModal() + } + + handleCancel = () => { + this.props.hideModal() + } + + render () { + const { t } = this.context + const { newGasFee } = this.props + + return ( + <Modal + headerText={t('attemptToCancel')} + onClose={this.handleCancel} + onSubmit={this.handleSubmit} + onCancel={this.handleCancel} + submitText={t('yesLetsTry')} + cancelText={t('nevermind')} + submitType="secondary" + > + <div> + <div className="cancel-transaction__title"> + { t('cancellationGasFee') } + </div> + <div className="cancel-transaction__cancel-transaction-gas-fee-container"> + <CancelTransactionGasFee value={newGasFee} /> + </div> + <div className="cancel-transaction__description"> + { t('attemptToCancelDescription') } + </div> + </div> + </Modal> + ) + } +} diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js b/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js new file mode 100644 index 000000000..eede8b1ee --- /dev/null +++ b/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js @@ -0,0 +1,62 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import ethUtil from 'ethereumjs-util' +import { multiplyCurrencies } from '../../../conversion-util' +import withModalProps from '../../../higher-order-components/with-modal-props' +import CancelTransaction from './cancel-transaction.component' +import { showModal, createCancelTransaction } from '../../../actions' +import { getHexGasTotal } from '../../../helpers/confirm-transaction/util' + +const mapStateToProps = (state, ownProps) => { + const { metamask } = state + const { transactionId, originalGasPrice } = ownProps + const { selectedAddressTxList } = metamask + const transaction = selectedAddressTxList.find(({ id }) => id === transactionId) + const transactionStatus = transaction ? transaction.status : '' + + const defaultNewGasPrice = ethUtil.addHexPrefix( + multiplyCurrencies(originalGasPrice, 1.1, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 10, + }) + ) + + const newGasFee = getHexGasTotal({ gasPrice: defaultNewGasPrice, gasLimit: '0x5208' }) + + return { + transactionId, + transactionStatus, + originalGasPrice, + newGasFee, + } +} + +const mapDispatchToProps = dispatch => { + return { + createCancelTransaction: txId => dispatch(createCancelTransaction(txId)), + showTransactionConfirmedModal: () => dispatch(showModal({ name: 'TRANSACTION_CONFIRMED' })), + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { transactionId, ...restStateProps } = stateProps + const { + createCancelTransaction: dispatchCreateCancelTransaction, + ...restDispatchProps + } = dispatchProps + + return { + ...restStateProps, + ...restDispatchProps, + ...ownProps, + createCancelTransaction: newGasPrice => { + return dispatchCreateCancelTransaction(transactionId, newGasPrice) + }, + } +} + +export default compose( + withModalProps, + connect(mapStateToProps, mapDispatchToProps, mergeProps), +)(CancelTransaction) diff --git a/ui/app/components/modals/cancel-transaction/index.js b/ui/app/components/modals/cancel-transaction/index.js new file mode 100644 index 000000000..7abc871ee --- /dev/null +++ b/ui/app/components/modals/cancel-transaction/index.js @@ -0,0 +1 @@ +export { default } from './cancel-transaction.container' diff --git a/ui/app/components/modals/cancel-transaction/index.scss b/ui/app/components/modals/cancel-transaction/index.scss new file mode 100644 index 000000000..62e8e36fd --- /dev/null +++ b/ui/app/components/modals/cancel-transaction/index.scss @@ -0,0 +1,18 @@ +@import './cancel-transaction-gas-fee/index'; + +.cancel-transaction { + &__title { + font-weight: 500; + padding-bottom: 16px; + text-align: center; + } + + &__description { + text-align: center; + font-size: .875rem; + } + + &__cancel-transaction-gas-fee-container { + margin-bottom: 16px; + } +}
\ No newline at end of file diff --git a/ui/app/components/modals/cancel-transaction/tests/cancel-transaction.component.test.js b/ui/app/components/modals/cancel-transaction/tests/cancel-transaction.component.test.js new file mode 100644 index 000000000..858fb01a8 --- /dev/null +++ b/ui/app/components/modals/cancel-transaction/tests/cancel-transaction.component.test.js @@ -0,0 +1,56 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import sinon from 'sinon' +import CancelTransaction from '../cancel-transaction.component' +import CancelTransactionGasFee from '../cancel-transaction-gas-fee' +import Modal from '../../../modal' + +describe('CancelTransaction Component', () => { + const t = key => key + + it('should render a CancelTransaction modal', () => { + const wrapper = shallow( + <CancelTransaction + newGasFee="0x1319718a5000" + />, + { context: { t }} + ) + + assert.ok(wrapper) + assert.equal(wrapper.find(Modal).length, 1) + assert.equal(wrapper.find(CancelTransactionGasFee).length, 1) + assert.equal(wrapper.find(CancelTransactionGasFee).props().value, '0x1319718a5000') + assert.equal(wrapper.find('.cancel-transaction__title').text(), 'cancellationGasFee') + assert.equal(wrapper.find('.cancel-transaction__description').text(), 'attemptToCancelDescription') + }) + + it('should pass the correct props to the Modal component', async () => { + const createCancelTransactionSpy = sinon.stub().callsFake(() => Promise.resolve()) + const hideModalSpy = sinon.spy() + + const wrapper = shallow( + <CancelTransaction + defaultNewGasPrice="0x3b9aca00" + createCancelTransaction={createCancelTransactionSpy} + hideModal={hideModalSpy} + />, + { context: { t }} + ) + + assert.equal(wrapper.find(Modal).length, 1) + const modalProps = wrapper.find(Modal).props() + + assert.equal(modalProps.headerText, 'attemptToCancel') + assert.equal(modalProps.submitText, 'yesLetsTry') + assert.equal(modalProps.cancelText, 'nevermind') + + assert.equal(createCancelTransactionSpy.callCount, 0) + assert.equal(hideModalSpy.callCount, 0) + await modalProps.onSubmit() + assert.equal(createCancelTransactionSpy.callCount, 1) + assert.equal(hideModalSpy.callCount, 1) + modalProps.onCancel() + assert.equal(hideModalSpy.callCount, 2) + }) +}) diff --git a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.component.js b/ui/app/components/modals/confirm-remove-account/confirm-remove-account.component.js index 5a9f0f289..195c55421 100644 --- a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.component.js +++ b/ui/app/components/modals/confirm-remove-account/confirm-remove-account.component.js @@ -1,11 +1,11 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import Button from '../../button' +import Modal from '../../modal' import { addressSummary } from '../../../util' import Identicon from '../../identicon' import genAccountLink from '../../../../lib/account-link' -class ConfirmRemoveAccount extends Component { +export default class ConfirmRemoveAccount extends Component { static propTypes = { hideModal: PropTypes.func.isRequired, removeAccount: PropTypes.func.isRequired, @@ -17,30 +17,34 @@ class ConfirmRemoveAccount extends Component { t: PropTypes.func, } - handleRemove () { + handleRemove = () => { this.props.removeAccount(this.props.identity.address) .then(() => this.props.hideModal()) } + handleCancel = () => { + this.props.hideModal() + } + renderSelectedAccount () { const { identity } = this.props return ( - <div className="modal-container__account"> - <div className="modal-container__account__identicon"> + <div className="confirm-remove-account__account"> + <div className="confirm-remove-account__account__identicon"> <Identicon - address={identity.address} - diameter={32} + address={identity.address} + diameter={32} /> </div> - <div className="modal-container__account__name"> - <span className="modal-container__account__label">Name</span> - <span className="account_value">{identity.name}</span> + <div className="confirm-remove-account__account__name"> + <span className="confirm-remove-account__account__label">Name</span> + <span className="account_value">{identity.name}</span> </div> - <div className="modal-container__account__address"> - <span className="modal-container__account__label">Public Address</span> - <span className="account_value">{ addressSummary(identity.address, 4, 4) }</span> + <div className="confirm-remove-account__account__address"> + <span className="confirm-remove-account__account__label">Public Address</span> + <span className="account_value">{ addressSummary(identity.address, 4, 4) }</span> </div> - <div className="modal-container__account__link"> + <div className="confirm-remove-account__account__link"> <a className="" href={genAccountLink(identity.address, this.props.network)} @@ -58,36 +62,28 @@ class ConfirmRemoveAccount extends Component { const { t } = this.context return ( - <div className="modal-container"> - <div className="modal-container__content"> - <div className="modal-container__title"> - { `${t('removeAccount')}` }? - </div> - { this.renderSelectedAccount() } - <div className="modal-container__description"> + <Modal + headerText={`${t('removeAccount')}?`} + onClose={this.handleCancel} + onSubmit={this.handleRemove} + onCancel={this.handleCancel} + submitText={t('remove')} + cancelText={t('nevermind')} + submitType="secondary" + > + <div> + { this.renderSelectedAccount() } + <div className="confirm-remove-account__description"> { t('removeAccountDescription') } - <a className="modal-container__link" rel="noopener noreferrer" target="_blank" href="https://consensys.zendesk.com/hc/en-us/articles/360004180111-What-are-imported-accounts-New-UI-">{ t('learnMore') }</a> + <a + className="confirm-remove-account__link" + rel="noopener noreferrer" + target="_blank" href="https://metamask.zendesk.com/hc/en-us/articles/360015289932"> + { t('learnMore') } + </a> </div> </div> - <div className="modal-container__footer"> - <Button - type="default" - className="modal-container__footer-button" - onClick={() => this.props.hideModal()} - > - { t('nevermind') } - </Button> - <Button - type="secondary" - className="modal-container__footer-button" - onClick={() => this.handleRemove()} - > - { t('remove') } - </Button> - </div> - </div> + </Modal> ) } } - -export default ConfirmRemoveAccount diff --git a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.container.js b/ui/app/components/modals/confirm-remove-account/confirm-remove-account.container.js index 4b194c995..45c6654ab 100644 --- a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.container.js +++ b/ui/app/components/modals/confirm-remove-account/confirm-remove-account.container.js @@ -1,20 +1,22 @@ import { connect } from 'react-redux' +import { compose } from 'recompose' import ConfirmRemoveAccount from './confirm-remove-account.component' - -const { hideModal, removeAccount } = require('../../../actions') +import withModalProps from '../../../higher-order-components/with-modal-props' +import { removeAccount } from '../../../actions' const mapStateToProps = state => { return { - identity: state.appState.modal.modalState.props.identity, network: state.metamask.network, } } const mapDispatchToProps = dispatch => { return { - hideModal: () => dispatch(hideModal()), removeAccount: (address) => dispatch(removeAccount(address)), } } -export default connect(mapStateToProps, mapDispatchToProps)(ConfirmRemoveAccount) +export default compose( + withModalProps, + connect(mapStateToProps, mapDispatchToProps) +)(ConfirmRemoveAccount) diff --git a/ui/app/components/modals/confirm-remove-account/index.js b/ui/app/components/modals/confirm-remove-account/index.js index 9763fbe05..ecb5f7790 100644 --- a/ui/app/components/modals/confirm-remove-account/index.js +++ b/ui/app/components/modals/confirm-remove-account/index.js @@ -1,2 +1 @@ -import ConfirmRemoveAccount from './confirm-remove-account.container' -module.exports = ConfirmRemoveAccount +export { default } from './confirm-remove-account.container' diff --git a/ui/app/components/modals/confirm-remove-account/index.scss b/ui/app/components/modals/confirm-remove-account/index.scss new file mode 100644 index 000000000..3be3a1967 --- /dev/null +++ b/ui/app/components/modals/confirm-remove-account/index.scss @@ -0,0 +1,58 @@ +.confirm-remove-account { + &__description { + text-align: center; + font-size: .875rem; + } + + &__account { + border: 1px solid #b7b7b7; + border-radius: 4px; + padding: 10px; + display: flex; + margin-top: 10px; + margin-bottom: 20px; + width: 100%; + + &__identicon { + margin-right: 10px; + } + + &__name, + &__address { + margin-right: 10px; + font-size: 14px; + } + + &__name { + width: 100px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__label { + font-size: 11px; + display: block; + color: #9b9b9b; + } + + &__link { + margin-top: 14px; + + img { + width: 15px; + height: 15px; + } + } + + @media screen and (max-width: 575px) { + &__name { + width: 90px; + } + } + } + + &__link { + color: #2f9ae0; + } +}
\ No newline at end of file diff --git a/ui/app/components/modals/confirm-reset-account/confirm-reset-account.component.js b/ui/app/components/modals/confirm-reset-account/confirm-reset-account.component.js index 14a4da62a..f1a4542ac 100644 --- a/ui/app/components/modals/confirm-reset-account/confirm-reset-account.component.js +++ b/ui/app/components/modals/confirm-reset-account/confirm-reset-account.component.js @@ -1,8 +1,8 @@ -import React, { Component } from 'react' +import React, { PureComponent } from 'react' import PropTypes from 'prop-types' -import Button from '../../button' +import Modal, { ModalContent } from '../../modal' -class ConfirmResetAccount extends Component { +export default class ConfirmResetAccount extends PureComponent { static propTypes = { hideModal: PropTypes.func.isRequired, resetAccount: PropTypes.func.isRequired, @@ -12,7 +12,7 @@ class ConfirmResetAccount extends Component { t: PropTypes.func, } - handleReset () { + handleReset = () => { this.props.resetAccount() .then(() => this.props.hideModal()) } @@ -21,34 +21,18 @@ class ConfirmResetAccount extends Component { const { t } = this.context return ( - <div className="modal-container"> - <div className="modal-container__content"> - <div className="modal-container__title"> - { `${t('resetAccount')}?` } - </div> - <div className="modal-container__description"> - { t('resetAccountDescription') } - </div> - </div> - <div className="modal-container__footer"> - <Button - type="default" - className="modal-container__footer-button" - onClick={() => this.props.hideModal()} - > - { t('nevermind') } - </Button> - <Button - type="secondary" - className="modal-container__footer-button" - onClick={() => this.handleReset()} - > - { t('reset') } - </Button> - </div> - </div> + <Modal + onSubmit={this.handleReset} + onCancel={() => this.props.hideModal()} + submitText={t('reset')} + cancelText={t('nevermind')} + submitType="secondary" + > + <ModalContent + title={`${t('resetAccount')}?`} + description={t('resetAccountDescription')} + /> + </Modal> ) } } - -export default ConfirmResetAccount diff --git a/ui/app/components/modals/confirm-reset-account/confirm-reset-account.container.js b/ui/app/components/modals/confirm-reset-account/confirm-reset-account.container.js index 9630a5593..c8a7b8478 100644 --- a/ui/app/components/modals/confirm-reset-account/confirm-reset-account.container.js +++ b/ui/app/components/modals/confirm-reset-account/confirm-reset-account.container.js @@ -1,13 +1,16 @@ import { connect } from 'react-redux' +import { compose } from 'recompose' +import withModalProps from '../../../higher-order-components/with-modal-props' import ConfirmResetAccount from './confirm-reset-account.component' - -const { hideModal, resetAccount } = require('../../../actions') +import { resetAccount } from '../../../actions' const mapDispatchToProps = dispatch => { return { - hideModal: () => dispatch(hideModal()), resetAccount: () => dispatch(resetAccount()), } } -export default connect(null, mapDispatchToProps)(ConfirmResetAccount) +export default compose( + withModalProps, + connect(null, mapDispatchToProps) +)(ConfirmResetAccount) diff --git a/ui/app/components/modals/confirm-reset-account/index.js b/ui/app/components/modals/confirm-reset-account/index.js index c812ffc55..ca4d9c5bf 100644 --- a/ui/app/components/modals/confirm-reset-account/index.js +++ b/ui/app/components/modals/confirm-reset-account/index.js @@ -1,2 +1 @@ -import ConfirmResetAccount from './confirm-reset-account.container' -module.exports = ConfirmResetAccount +export { default } from './confirm-reset-account.container' diff --git a/ui/app/components/modals/customize-gas/customize-gas.component.js b/ui/app/components/modals/customize-gas/customize-gas.component.js index 0337c5413..3f526bd43 100644 --- a/ui/app/components/modals/customize-gas/customize-gas.component.js +++ b/ui/app/components/modals/customize-gas/customize-gas.component.js @@ -2,6 +2,7 @@ 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 Button from '../../button' import { getDecimalGasLimit, @@ -116,21 +117,23 @@ export default class CustomizeGas extends Component { { t('revert') } </div> <div className="customize-gas__buttons"> - <button - className="btn-default customize-gas__cancel" + <Button + type="default" + className="customize-gas__cancel" onClick={() => hideModal()} style={{ marginRight: '10px' }} > { t('cancel') } - </button> - <button - className="btn-primary customize-gas__save" + </Button> + <Button + type="primary" + className="customize-gas__save" onClick={() => this.handleSave()} style={{ marginRight: '10px' }} disabled={!valid} > { t('save') } - </button> + </Button> </div> </div> </div> diff --git a/ui/app/components/modals/deposit-ether-modal.js b/ui/app/components/modals/deposit-ether-modal.js index 2daa7fa1d..09137d39a 100644 --- a/ui/app/components/modals/deposit-ether-modal.js +++ b/ui/app/components/modals/deposit-ether-modal.js @@ -7,6 +7,8 @@ const actions = require('../../actions') const { getNetworkDisplayName } = require('../../../../app/scripts/controllers/network/util') const ShapeshiftForm = require('../shapeshift-form') +import Button from '../button' + let DIRECT_DEPOSIT_ROW_TITLE let DIRECT_DEPOSIT_ROW_TEXT let COINBASE_ROW_TITLE @@ -109,7 +111,10 @@ DepositEtherModal.prototype.renderRow = function ({ ]), !hideButton && h('div.deposit-ether-modal__buy-row__button', [ - h('button.btn-primary.btn--large.deposit-ether-modal__deposit-button', { + h(Button, { + type: 'primary', + className: 'deposit-ether-modal__deposit-button', + large: true, onClick: onButtonClick, }, [buttonLabel]), ]), diff --git a/ui/app/components/modals/export-private-key-modal.js b/ui/app/components/modals/export-private-key-modal.js index 80ece425f..d3e3c9a56 100644 --- a/ui/app/components/modals/export-private-key-modal.js +++ b/ui/app/components/modals/export-private-key-modal.js @@ -1,3 +1,4 @@ +const log = require('loglevel') const Component = require('react').Component const PropTypes = require('prop-types') const h = require('react-hyperscript') @@ -10,20 +11,35 @@ const { getSelectedIdentity } = require('../../selectors') const ReadOnlyInput = require('../readonly-input') const copyToClipboard = require('copy-to-clipboard') const { checksumAddress } = require('../../util') - -function mapStateToProps (state) { - return { - warning: state.appState.warning, - privateKey: state.appState.accountDetail.privateKey, - network: state.metamask.network, - selectedIdentity: getSelectedIdentity(state), - previousModalState: state.appState.modal.previousModalState.name, +import Button from '../button' + +function mapStateToPropsFactory () { + let selectedIdentity = null + return function mapStateToProps (state) { + // We should **not** change the identity displayed here even if it changes from underneath us. + // If we do, we will be showing the user one private key and a **different** address and name. + // Note that the selected identity **will** change from underneath us when we unlock the keyring + // which is the expected behavior that we are side-stepping. + selectedIdentity = selectedIdentity || getSelectedIdentity(state) + return { + warning: state.appState.warning, + privateKey: state.appState.accountDetail.privateKey, + network: state.metamask.network, + selectedIdentity, + previousModalState: state.appState.modal.previousModalState.name, + } } } function mapDispatchToProps (dispatch) { return { - exportAccount: (password, address) => dispatch(actions.exportAccount(password, address)), + exportAccount: (password, address) => { + return dispatch(actions.exportAccount(password, address)) + .then((res) => { + dispatch(actions.hideWarning()) + return res + }) + }, showAccountDetailModal: () => dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })), hideModal: () => dispatch(actions.hideModal()), } @@ -36,6 +52,7 @@ function ExportPrivateKeyModal () { this.state = { password: '', privateKey: null, + showWarning: true, } } @@ -43,14 +60,18 @@ ExportPrivateKeyModal.contextTypes = { t: PropTypes.func, } -module.exports = connect(mapStateToProps, mapDispatchToProps)(ExportPrivateKeyModal) +module.exports = connect(mapStateToPropsFactory, mapDispatchToProps)(ExportPrivateKeyModal) ExportPrivateKeyModal.prototype.exportAccountAndGetPrivateKey = function (password, address) { const { exportAccount } = this.props exportAccount(password, address) - .then(privateKey => this.setState({ privateKey })) + .then(privateKey => this.setState({ + privateKey, + showWarning: false, + })) + .catch((e) => log.error(e)) } ExportPrivateKeyModal.prototype.renderPasswordLabel = function (privateKey) { @@ -77,24 +98,31 @@ ExportPrivateKeyModal.prototype.renderPasswordInput = function (privateKey) { }) } -ExportPrivateKeyModal.prototype.renderButton = function (className, onClick, label) { - return h('button', { - className, - onClick, - }, label) -} - ExportPrivateKeyModal.prototype.renderButtons = function (privateKey, password, address, hideModal) { return h('div.export-private-key-buttons', {}, [ - !privateKey && this.renderButton( - 'btn-default btn--large export-private-key__button export-private-key__button--cancel', - () => hideModal(), - 'Cancel' - ), + !privateKey && h(Button, { + type: 'default', + large: true, + className: 'export-private-key__button export-private-key__button--cancel', + onClick: () => hideModal(), + }, this.context.t('cancel')), (privateKey - ? this.renderButton('btn-primary btn--large export-private-key__button', () => hideModal(), this.context.t('done')) - : this.renderButton('btn-primary btn--large export-private-key__button', () => this.exportAccountAndGetPrivateKey(this.state.password, address), this.context.t('confirm')) + ? ( + h(Button, { + type: 'primary', + large: true, + className: 'export-private-key__button', + onClick: () => hideModal(), + }, this.context.t('done')) + ) : ( + h(Button, { + type: 'primary', + large: true, + className: 'export-private-key__button', + onClick: () => this.exportAccountAndGetPrivateKey(this.state.password, address), + }, this.context.t('confirm')) + ) ), ]) @@ -110,9 +138,13 @@ ExportPrivateKeyModal.prototype.render = function () { } = this.props const { name, address } = selectedIdentity - const { privateKey } = this.state + const { + privateKey, + showWarning, + } = this.state return h(AccountModalContainer, { + selectedIdentity, showBackButton: previousModalState === 'ACCOUNT_DETAILS', backButtonAction: () => showAccountDetailModal(), }, [ @@ -134,7 +166,7 @@ ExportPrivateKeyModal.prototype.render = function () { this.renderPasswordInput(privateKey), - !warning ? null : h('span.private-key-password-error', warning), + showWarning && warning ? h('span.private-key-password-error', warning) : null, ]), h('div.private-key-password-warning', this.context.t('privateKeyWarning')), diff --git a/ui/app/components/modals/hide-token-confirmation-modal.js b/ui/app/components/modals/hide-token-confirmation-modal.js index 1518fa9a0..fb38516d3 100644 --- a/ui/app/components/modals/hide-token-confirmation-modal.js +++ b/ui/app/components/modals/hide-token-confirmation-modal.js @@ -10,6 +10,7 @@ function mapStateToProps (state) { return { network: state.metamask.network, token: state.appState.modal.modalState.props.token, + assetImages: state.metamask.assetImages, } } @@ -40,8 +41,9 @@ module.exports = connect(mapStateToProps, mapDispatchToProps)(HideTokenConfirmat HideTokenConfirmationModal.prototype.render = function () { - const { token, network, hideToken, hideModal } = this.props + const { token, network, hideToken, hideModal, assetImages } = this.props const { symbol, address } = token + const image = assetImages[address] return h('div.hide-token-confirmation', {}, [ h('div.hide-token-confirmation__container', { @@ -55,6 +57,7 @@ HideTokenConfirmationModal.prototype.render = function () { diameter: 45, address, network, + image, }), h('div.hide-token-confirmation__symbol', {}, symbol), diff --git a/ui/app/components/modals/index.scss b/ui/app/components/modals/index.scss index 0acccf172..45453a582 100644 --- a/ui/app/components/modals/index.scss +++ b/ui/app/components/modals/index.scss @@ -1,108 +1,9 @@ -@import './customize-gas/index'; - -@import './qr-scanner/index'; - -.modal-container { - width: 100%; - height: 100%; - background-color: #fff; - display: flex; - flex-flow: column; - border-radius: 8px; - - &__title { - font-size: 1.5rem; - font-weight: 500; - padding: 16px 0; - text-align: center; - } - - &__description { - text-align: center; - font-size: .875rem; - } - - &__account { - border: 1px solid #b7b7b7; - border-radius: 4px; - padding: 10px; - display: flex; - margin-top: 10px; - margin-bottom: 20px; - width: 100%; - - &__identicon { - margin-right: 10px; - } - - &__name, - &__address { - margin-right: 10px; - font-size: 14px; - } +@import './cancel-transaction/index'; - &__name { - width: 100px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } +@import './confirm-remove-account/index'; - &__label { - font-size: 11px; - display: block; - color: #9b9b9b; - } - - &__link { - margin-top: 14px; - - img { - width: 15px; - height: 15px; - } - } - - @media screen and (max-width: 575px) { - &__name { - width: 90px; - } - } - } - - &__link { - color: #2f9ae0; - } - - &__content { - overflow-y: auto; - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - padding: 32px; - - @media screen and (max-width: 575px) { - justify-content: center; - padding: 28px 20px; - } - } - - &__footer { - display: flex; - flex-flow: row; - justify-content: center; - border-top: 1px solid #d2d8dd; - padding: 16px; - flex: 0 0 auto; +@import './customize-gas/index'; - &-button { - min-width: 0; - margin-right: 16px; +@import './qr-scanner/index'; - &:last-of-type { - margin-right: 0; - } - } - } -} +@import './transaction-confirmed/index'; diff --git a/ui/app/components/modals/modal.js b/ui/app/components/modals/modal.js index 5dda50e52..15ca9deaa 100644 --- a/ui/app/components/modals/modal.js +++ b/ui/app/components/modals/modal.js @@ -19,14 +19,16 @@ const ShapeshiftDepositTxModal = require('./shapeshift-deposit-tx-modal.js') const HideTokenConfirmationModal = require('./hide-token-confirmation-modal') const CustomizeGasModal = require('../customize-gas-modal') const NotifcationModal = require('./notification-modal') -const ConfirmResetAccount = require('./confirm-reset-account') -const ConfirmRemoveAccount = require('./confirm-remove-account') const QRScanner = require('./qr-scanner') -const TransactionConfirmed = require('./transaction-confirmed') -const WelcomeBeta = require('./welcome-beta') -const Notification = require('./notification') +import ConfirmRemoveAccount from './confirm-remove-account' +import ConfirmResetAccount from './confirm-reset-account' +import TransactionConfirmed from './transaction-confirmed' import ConfirmCustomizeGasModal from './customize-gas' +import CancelTransaction from './cancel-transaction' +import WelcomeBeta from './welcome-beta' +import TransactionDetails from './transaction-details' +import RejectTransactions from './reject-transactions' const modalContainerBaseStyle = { transform: 'translate3d(-50%, 0, 0px)', @@ -199,11 +201,7 @@ const MODALS = { }, BETA_UI_NOTIFICATION_MODAL: { - contents: [ - h(Notification, [ - h(WelcomeBeta), - ]), - ], + contents: h(WelcomeBeta), mobileModalStyle: { ...modalContainerMobileStyle, }, @@ -307,9 +305,7 @@ const MODALS = { }, CONFIRM_CUSTOMIZE_GAS: { - contents: [ - h(ConfirmCustomizeGasModal), - ], + contents: h(ConfirmCustomizeGasModal), mobileModalStyle: { width: '100vw', height: '100vh', @@ -332,11 +328,7 @@ const MODALS = { TRANSACTION_CONFIRMED: { disableBackdropClick: true, - contents: [ - h(Notification, [ - h(TransactionConfirmed), - ]), - ], + contents: h(TransactionConfirmed), mobileModalStyle: { ...modalContainerMobileStyle, }, @@ -347,6 +339,7 @@ const MODALS = { borderRadius: '8px', }, }, + QR_SCANNER: { contents: h(QRScanner), mobileModalStyle: { @@ -360,6 +353,45 @@ const MODALS = { }, }, + CANCEL_TRANSACTION: { + contents: h(CancelTransaction), + mobileModalStyle: { + ...modalContainerMobileStyle, + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + }, + contentStyle: { + borderRadius: '8px', + }, + }, + + TRANSACTION_DETAILS: { + contents: h(TransactionDetails), + mobileModalStyle: { + ...modalContainerMobileStyle, + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + }, + contentStyle: { + borderRadius: '8px', + }, + }, + + REJECT_TRANSACTIONS: { + contents: h(RejectTransactions), + mobileModalStyle: { + ...modalContainerMobileStyle, + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + }, + contentStyle: { + borderRadius: '8px', + }, + }, + DEFAULT: { contents: [], mobileModalStyle: {}, diff --git a/ui/app/components/modals/notification/index.js b/ui/app/components/modals/notification/index.js deleted file mode 100644 index d60a3129b..000000000 --- a/ui/app/components/modals/notification/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import Notification from './notification.container' -module.exports = Notification diff --git a/ui/app/components/modals/notification/notification.component.js b/ui/app/components/modals/notification/notification.component.js deleted file mode 100644 index 1af2f3ca8..000000000 --- a/ui/app/components/modals/notification/notification.component.js +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import Button from '../../button' - -const Notification = (props, context) => { - return ( - <div className="modal-container"> - { props.children } - <div className="modal-container__footer"> - <Button - type="primary" - onClick={() => props.onHide()} - > - { context.t('ok') } - </Button> - </div> - </div> - ) -} - -Notification.propTypes = { - onHide: PropTypes.func.isRequired, - children: PropTypes.element, -} - -Notification.contextTypes = { - t: PropTypes.func, -} - -export default Notification diff --git a/ui/app/components/modals/notification/notification.container.js b/ui/app/components/modals/notification/notification.container.js deleted file mode 100644 index 5b98714da..000000000 --- a/ui/app/components/modals/notification/notification.container.js +++ /dev/null @@ -1,38 +0,0 @@ -import { connect } from 'react-redux' -import Notification from './notification.component' - -const { hideModal } = require('../../../actions') - -const mapStateToProps = state => { - const { appState: { modal: { modalState: { props } } } } = state - const { onHide } = props - return { - onHide, - } -} - -const mapDispatchToProps = dispatch => { - return { - hideModal: () => dispatch(hideModal()), - } -} - -const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { onHide, ...otherStateProps } = stateProps - const { hideModal, ...otherDispatchProps } = dispatchProps - - return { - ...otherStateProps, - ...otherDispatchProps, - ...ownProps, - onHide: () => { - hideModal() - - if (onHide && typeof onHide === 'function') { - onHide() - } - }, - } -} - -export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(Notification) diff --git a/ui/app/components/modals/reject-transactions/index.js b/ui/app/components/modals/reject-transactions/index.js new file mode 100644 index 000000000..fcdc372b6 --- /dev/null +++ b/ui/app/components/modals/reject-transactions/index.js @@ -0,0 +1 @@ +export { default } from './reject-transactions.container' diff --git a/ui/app/components/modals/reject-transactions/index.scss b/ui/app/components/modals/reject-transactions/index.scss new file mode 100644 index 000000000..753466883 --- /dev/null +++ b/ui/app/components/modals/reject-transactions/index.scss @@ -0,0 +1,6 @@ +.reject-transactions { + &__description { + text-align: center; + font-size: .875rem; + } +} diff --git a/ui/app/components/modals/reject-transactions/reject-transactions.component.js b/ui/app/components/modals/reject-transactions/reject-transactions.component.js new file mode 100644 index 000000000..60b259bdc --- /dev/null +++ b/ui/app/components/modals/reject-transactions/reject-transactions.component.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types' +import React, { PureComponent } from 'react' +import Modal from '../../modal' + +export default class RejectTransactionsModal extends PureComponent { + static contextTypes = { + t: PropTypes.func.isRequired, + } + + static propTypes = { + onSubmit: PropTypes.func.isRequired, + hideModal: PropTypes.func.isRequired, + unapprovedTxCount: PropTypes.number.isRequired, + } + + onSubmit = async () => { + const { onSubmit, hideModal } = this.props + + await onSubmit() + hideModal() + } + + render () { + const { t } = this.context + const { hideModal, unapprovedTxCount } = this.props + + return ( + <Modal + headerText={t('rejectTxsN', [unapprovedTxCount])} + onClose={hideModal} + onSubmit={this.onSubmit} + onCancel={hideModal} + submitText={t('rejectAll')} + cancelText={t('cancel')} + submitType="secondary" + > + <div> + <div className="reject-transactions__description"> + { t('rejectTxsDescription', [unapprovedTxCount]) } + </div> + </div> + </Modal> + ) + } +} diff --git a/ui/app/components/modals/reject-transactions/reject-transactions.container.js b/ui/app/components/modals/reject-transactions/reject-transactions.container.js new file mode 100644 index 000000000..81e98d3ff --- /dev/null +++ b/ui/app/components/modals/reject-transactions/reject-transactions.container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import RejectTransactionsModal from './reject-transactions.component' +import withModalProps from '../../../higher-order-components/with-modal-props' + +const mapStateToProps = (state, ownProps) => { + const { unapprovedTxCount } = ownProps + + return { + unapprovedTxCount, + } +} + +export default compose( + withModalProps, + connect(mapStateToProps), +)(RejectTransactionsModal) diff --git a/ui/app/components/modals/transaction-confirmed/index.js b/ui/app/components/modals/transaction-confirmed/index.js index cee8da7f8..7776b969e 100644 --- a/ui/app/components/modals/transaction-confirmed/index.js +++ b/ui/app/components/modals/transaction-confirmed/index.js @@ -1,2 +1 @@ -import TransactionConfirmed from './transaction-confirmed.component' -module.exports = TransactionConfirmed +export { default } from './transaction-confirmed.container' diff --git a/ui/app/components/modals/transaction-confirmed/index.scss b/ui/app/components/modals/transaction-confirmed/index.scss new file mode 100644 index 000000000..c97371fb6 --- /dev/null +++ b/ui/app/components/modals/transaction-confirmed/index.scss @@ -0,0 +1,22 @@ +.transaction-confirmed { + &__title { + font-size: 1.5rem; + font-weight: 500; + padding: 16px 0; + text-align: center; + } + + &__description { + text-align: center; + font-size: .875rem; + } + + &__content { + overflow-y: auto; + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 16px; + } +} diff --git a/ui/app/components/modals/transaction-confirmed/transaction-confirmed.component.js b/ui/app/components/modals/transaction-confirmed/transaction-confirmed.component.js index c1c8a2976..0a98eb1a1 100644 --- a/ui/app/components/modals/transaction-confirmed/transaction-confirmed.component.js +++ b/ui/app/components/modals/transaction-confirmed/transaction-confirmed.component.js @@ -1,24 +1,45 @@ -import React from 'react' +import React, { PureComponent } from 'react' import PropTypes from 'prop-types' +import Modal from '../../modal' -const TransactionConfirmed = (props, context) => { - const { t } = context +export default class TransactionConfirmed extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } - return ( - <div className="modal-container__content"> - <img src="images/check-icon.svg" /> - <div className="modal-container__title"> - { `${t('confirmed')}!` } - </div> - <div className="modal-container__description"> - { t('initialTransactionConfirmed') } - </div> - </div> - ) -} + static propTypes = { + onSubmit: PropTypes.func, + hideModal: PropTypes.func, + } -TransactionConfirmed.contextTypes = { - t: PropTypes.func, -} + handleSubmit = () => { + const { hideModal, onSubmit } = this.props + + hideModal() -export default TransactionConfirmed + if (onSubmit && typeof onSubmit === 'function') { + onSubmit() + } + } + + render () { + const { t } = this.context + + return ( + <Modal + onSubmit={this.handleSubmit} + submitText={t('ok')} + > + <div className="transaction-confirmed__content"> + <img src="images/check-icon.svg" /> + <div className="transaction-confirmed__title"> + { `${t('confirmed')}!` } + </div> + <div className="transaction-confirmed__description"> + { t('initialTransactionConfirmed') } + </div> + </div> + </Modal> + ) + } +} diff --git a/ui/app/components/modals/transaction-confirmed/transaction-confirmed.container.js b/ui/app/components/modals/transaction-confirmed/transaction-confirmed.container.js new file mode 100644 index 000000000..d4e39681a --- /dev/null +++ b/ui/app/components/modals/transaction-confirmed/transaction-confirmed.container.js @@ -0,0 +1,4 @@ +import TransactionConfirmed from './transaction-confirmed.component' +import withModalProps from '../../../higher-order-components/with-modal-props' + +export default withModalProps(TransactionConfirmed) diff --git a/ui/app/components/modals/transaction-details/index.js b/ui/app/components/modals/transaction-details/index.js new file mode 100644 index 000000000..1fc42c662 --- /dev/null +++ b/ui/app/components/modals/transaction-details/index.js @@ -0,0 +1 @@ +export { default } from './transaction-details.container' diff --git a/ui/app/components/modals/transaction-details/transaction-details.component.js b/ui/app/components/modals/transaction-details/transaction-details.component.js new file mode 100644 index 000000000..f2fec3409 --- /dev/null +++ b/ui/app/components/modals/transaction-details/transaction-details.component.js @@ -0,0 +1,54 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Modal from '../../modal' +import TransactionListItemDetails from '../../transaction-list-item-details' +import { hexToDecimal } from '../../../helpers/conversions.util' + +export default class TransactionConfirmed extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + hideModal: PropTypes.func, + transaction: PropTypes.object, + onRetry: PropTypes.func, + showRetry: PropTypes.bool, + onCancel: PropTypes.func, + showCancel: PropTypes.bool, + } + + handleSubmit = () => { + this.props.hideModal() + } + + handleRetry = () => { + const { onRetry, hideModal } = this.props + + Promise.resolve(onRetry()).then(() => hideModal()) + } + + render () { + const { t } = this.context + const { transaction, showRetry, onCancel, showCancel } = this.props + const { txParams: { nonce } = {} } = transaction + const decimalNonce = nonce && hexToDecimal(nonce) + + return ( + <Modal + onSubmit={this.handleSubmit} + onClose={this.handleSubmit} + submitText={t('ok')} + headerText={t('transactionWithNonce', [`#${decimalNonce}`])} + > + <TransactionListItemDetails + transaction={transaction} + onRetry={this.handleRetry} + showRetry={showRetry} + onCancel={() => onCancel()} + showCancel={showCancel} + /> + </Modal> + ) + } +} diff --git a/ui/app/components/modals/transaction-details/transaction-details.container.js b/ui/app/components/modals/transaction-details/transaction-details.container.js new file mode 100644 index 000000000..f212920bb --- /dev/null +++ b/ui/app/components/modals/transaction-details/transaction-details.container.js @@ -0,0 +1,4 @@ +import TransactionDetails from './transaction-details.component' +import withModalProps from '../../../higher-order-components/with-modal-props' + +export default withModalProps(TransactionDetails) diff --git a/ui/app/components/modals/welcome-beta/index.js b/ui/app/components/modals/welcome-beta/index.js index 515c9cdaf..49e45b9d7 100644 --- a/ui/app/components/modals/welcome-beta/index.js +++ b/ui/app/components/modals/welcome-beta/index.js @@ -1,2 +1 @@ -import WelcomeBeta from './welcome-beta.component' -module.exports = WelcomeBeta +export { default } from './welcome-beta.container' diff --git a/ui/app/components/modals/welcome-beta/welcome-beta.component.js b/ui/app/components/modals/welcome-beta/welcome-beta.component.js index 61571723a..ef1799164 100644 --- a/ui/app/components/modals/welcome-beta/welcome-beta.component.js +++ b/ui/app/components/modals/welcome-beta/welcome-beta.component.js @@ -1,18 +1,21 @@ import React from 'react' import PropTypes from 'prop-types' +import Modal, { ModalContent } from '../../modal' const TransactionConfirmed = (props, context) => { const { t } = context + const { hideModal } = props return ( - <div className="modal-container__content"> - <div className="modal-container__title"> - { `${t('uiWelcome')}` } - </div> - <div className="modal-container__description"> - { t('uiWelcomeMessage') } - </div> - </div> + <Modal + onSubmit={() => hideModal()} + submitText={t('ok')} + > + <ModalContent + title={t('uiWelcome')} + description={t('uiWelcomeMessage')} + /> + </Modal> ) } @@ -20,4 +23,8 @@ TransactionConfirmed.contextTypes = { t: PropTypes.func, } +TransactionConfirmed.propTypes = { + hideModal: PropTypes.func, +} + export default TransactionConfirmed diff --git a/ui/app/components/modals/welcome-beta/welcome-beta.container.js b/ui/app/components/modals/welcome-beta/welcome-beta.container.js new file mode 100644 index 000000000..c5123ad47 --- /dev/null +++ b/ui/app/components/modals/welcome-beta/welcome-beta.container.js @@ -0,0 +1,4 @@ +import WelcomeBeta from './welcome-beta.component' +import withModalProps from '../../../higher-order-components/with-modal-props' + +export default withModalProps(WelcomeBeta) diff --git a/ui/app/components/page-container/index.scss b/ui/app/components/page-container/index.scss index 06c3ef709..6742e3082 100644 --- a/ui/app/components/page-container/index.scss +++ b/ui/app/components/page-container/index.scss @@ -43,16 +43,39 @@ &__footer { display: flex; - flex-flow: row; + flex-flow: column; justify-content: center; border-top: 1px solid $geyser; - padding: 16px; flex: 0 0 auto; .btn-default, .btn-confirm { font-size: 1rem; } + + header { + display: flex; + flex-flow: row; + justify-content: center; + padding: 16px; + flex: 0 0 auto; + } + + footer { + display: flex; + flex-flow: row; + justify-content: space-around; + padding: 0 16px 16px; + flex: 0 0 auto; + + a, a:hover { + text-decoration: none; + cursor: pointer; + font-size: 0.75rem; + text-transform: uppercase; + color: #2f9ae0; + } + } } &__footer-button { @@ -109,7 +132,7 @@ &--selected { color: $curious-blue; - border-bottom: 3px solid $curious-blue; + border-bottom: 2px solid $curious-blue; } } @@ -182,5 +205,7 @@ max-height: 82vh; min-height: 570px; flex: 0 0 auto; + margin-right: auto; + margin-left: 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 3d15df294..773fe1f56 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 @@ -5,6 +5,7 @@ import Button from '../../button' export default class PageContainerFooter extends Component { static propTypes = { + children: PropTypes.node, onCancel: PropTypes.func, cancelText: PropTypes.string, onSubmit: PropTypes.func, @@ -19,6 +20,7 @@ export default class PageContainerFooter extends Component { render () { const { + children, onCancel, cancelText, onSubmit, @@ -30,24 +32,32 @@ export default class PageContainerFooter extends Component { return ( <div className="page-container__footer"> - <Button - type="default" - large - className="page-container__footer-button" - onClick={e => onCancel(e)} - > - { cancelText || this.context.t('cancel') } - </Button> - - <Button - type={submitButtonType || 'primary'} - large - className="page-container__footer-button" - disabled={disabled} - onClick={e => onSubmit(e)} - > - { submitText || this.context.t('next') } - </Button> + <header> + <Button + type="default" + large + className="page-container__footer-button" + onClick={e => onCancel(e)} + > + { cancelText || this.context.t('cancel') } + </Button> + + <Button + type={submitButtonType || 'primary'} + large + className="page-container__footer-button" + disabled={disabled} + onClick={e => onSubmit(e)} + > + { submitText || this.context.t('next') } + </Button> + </header> + + {children && ( + <footer> + {children} + </footer> + )} </div> ) diff --git a/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js b/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js index e69de29bb..64efabab0 100644 --- a/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js +++ b/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js @@ -0,0 +1,79 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import sinon from 'sinon' +import Button from '../../../button' +import PageFooter from '../page-container-footer.component' + +describe('Page Footer', () => { + let wrapper + const onCancel = sinon.spy() + const onSubmit = sinon.spy() + + beforeEach(() => { + wrapper = shallow(<PageFooter + onCancel = {onCancel} + onSubmit = {onSubmit} + cancelText = {'Cancel'} + submitText = {'Submit'} + disabled = {false} + submitButtonType = {'Test Type'} + />) + }) + + it('renders page container footer', () => { + assert.equal(wrapper.find('.page-container__footer').length, 1) + }) + + it('should render a footer inside page-container__footer when given children', () => { + const wrapper = shallow( + <PageFooter> + <div>Works</div> + </PageFooter>, + { context: { t: sinon.spy((k) => `[${k}]`) } } + ) + + assert.equal(wrapper.find('.page-container__footer footer').length, 1) + }) + + it('renders two button components', () => { + assert.equal(wrapper.find(Button).length, 2) + }) + + describe('Cancel Button', () => { + + it('has button type of default', () => { + assert.equal(wrapper.find('.page-container__footer-button').first().prop('type'), 'default') + }) + + it('has children text of Cancel', () => { + assert.equal(wrapper.find('.page-container__footer-button').first().prop('children'), 'Cancel') + }) + + it('should call cancel when click is simulated', () => { + wrapper.find('.page-container__footer-button').first().prop('onClick')() + assert.equal(onCancel.callCount, 1) + }) + + }) + + describe('Submit Button', () => { + + it('assigns button type based on props', () => { + assert.equal(wrapper.find('.page-container__footer-button').last().prop('type'), 'Test Type') + }) + + it('has disabled prop', () => { + assert.equal(wrapper.find('.page-container__footer-button').last().prop('disabled'), false) + }) + + it('has children text when submitText prop exists', () => { + assert.equal(wrapper.find('.page-container__footer-button').last().prop('children'), 'Submit') + }) + + it('should call submit when click is simulated', () => { + wrapper.find('.page-container__footer-button').last().prop('onClick')() + assert.equal(onSubmit.callCount, 1) + }) + }) +}) 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 5a5de1e5a..a8458604e 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 @@ -1,8 +1,8 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' +import classnames from 'classnames' export default class PageContainerHeader extends Component { - static propTypes = { title: PropTypes.string, subtitle: PropTypes.string, @@ -11,8 +11,18 @@ export default class PageContainerHeader extends Component { onBackButtonClick: PropTypes.func, backButtonStyles: PropTypes.object, backButtonString: PropTypes.string, - children: PropTypes.node, - }; + tabs: PropTypes.node, + } + + renderTabs () { + const { tabs } = this.props + + return tabs && ( + <ul className="page-container__tabs"> + { tabs } + </ul> + ) + } renderHeaderRow () { const { showBackButton, onBackButtonClick, backButtonStyles, backButtonString } = this.props @@ -31,15 +41,18 @@ export default class PageContainerHeader extends Component { } render () { - const { title, subtitle, onClose, children } = this.props + const { title, subtitle, onClose, tabs } = this.props return ( - <div className="page-container__header"> + <div className={ + classnames( + 'page-container__header', + { 'page-container__header--no-padding-bottom': Boolean(tabs) } + ) + }> { this.renderHeaderRow() } - { children } - { title && <div className="page-container__title"> { title } @@ -59,6 +72,7 @@ export default class PageContainerHeader extends Component { /> } + { this.renderTabs() } </div> ) } diff --git a/ui/app/components/page-container/page-container-header/tests/page-container-header.component.test.js b/ui/app/components/page-container/page-container-header/tests/page-container-header.component.test.js index e69de29bb..59304b2bd 100644 --- a/ui/app/components/page-container/page-container-header/tests/page-container-header.component.test.js +++ b/ui/app/components/page-container/page-container-header/tests/page-container-header.component.test.js @@ -0,0 +1,82 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import sinon from 'sinon' +import PageContainerHeader from '../page-container-header.component' + +describe('Page Container Header', () => { + let wrapper, style, onBackButtonClick, onClose + + beforeEach(() => { + style = {test: 'style'} + onBackButtonClick = sinon.spy() + onClose = sinon.spy() + + wrapper = shallow(<PageContainerHeader + showBackButton = {true} + onBackButtonClick = {onBackButtonClick} + backButtonStyles = {style} + title = {'Test Title'} + subtitle = {'Test Subtitle'} + tabs = {'Test Tab'} + onClose = {onClose} + />) + }) + + describe('Render Header Row', () => { + + it('renders back button', () => { + assert.equal(wrapper.find('.page-container__back-button').length, 1) + assert.equal(wrapper.find('.page-container__back-button').text(), 'Back') + }) + + it('ensures style prop', () => { + assert.equal(wrapper.find('.page-container__back-button').props().style, style) + }) + + it('should call back button when click is simulated', () => { + wrapper.find('.page-container__back-button').prop('onClick')() + assert.equal(onBackButtonClick.callCount, 1) + }) + }) + + describe('Render', () => { + let header, headerRow, pageTitle, pageSubtitle, pageClose, pageTab + + beforeEach(() => { + header = wrapper.find('.page-container__header--no-padding-bottom') + headerRow = wrapper.find('.page-container__header-row') + pageTitle = wrapper.find('.page-container__title') + pageSubtitle = wrapper.find('.page-container__subtitle') + pageClose = wrapper.find('.page-container__header-close') + pageTab = wrapper.find('.page-container__tabs') + }) + + it('renders page container', () => { + assert.equal(header.length, 1) + assert.equal(headerRow.length, 1) + assert.equal(pageTitle.length, 1) + assert.equal(pageSubtitle.length, 1) + assert.equal(pageClose.length, 1) + assert.equal(pageTab.length, 1) + }) + + it('renders title', () => { + assert.equal(pageTitle.text(), 'Test Title') + }) + + it('renders subtitle', () => { + assert.equal(pageSubtitle.text(), 'Test Subtitle') + }) + + it('renders tabs', () => { + assert.equal(pageTab.text(), 'Test Tab') + }) + + it('should call close when click is simulated', () => { + pageClose.prop('onClick')() + assert.equal(onClose.callCount, 1) + }) + }) + +}) diff --git a/ui/app/components/page-container/page-container.component.js b/ui/app/components/page-container/page-container.component.js index 9bfb99ade..3a2274a29 100644 --- a/ui/app/components/page-container/page-container.component.js +++ b/ui/app/components/page-container/page-container.component.js @@ -1,30 +1,82 @@ -import React, { Component } from 'react' +import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import PageContainerHeader from './page-container-header' import PageContainerFooter from './page-container-footer' -export default class PageContainer extends Component { - +export default class PageContainer extends PureComponent { static propTypes = { // PageContainerHeader props - title: PropTypes.string.isRequired, - subtitle: PropTypes.string, + backButtonString: PropTypes.string, + backButtonStyles: PropTypes.object, + onBackButtonClick: PropTypes.func, onClose: PropTypes.func, showBackButton: PropTypes.bool, - onBackButtonClick: PropTypes.func, - backButtonStyles: PropTypes.object, - backButtonString: PropTypes.string, + subtitle: PropTypes.string, + title: PropTypes.string.isRequired, + // Tabs-related props + defaultActiveTabIndex: PropTypes.number, + tabsComponent: PropTypes.node, // Content props - ContentComponent: PropTypes.func, - contentComponentProps: PropTypes.object, + contentComponent: PropTypes.node, // PageContainerFooter props - onCancel: PropTypes.func, cancelText: PropTypes.string, + disabled: PropTypes.bool, + onCancel: PropTypes.func, onSubmit: PropTypes.func, submitText: PropTypes.string, - disabled: PropTypes.bool, - }; + } + + state = { + activeTabIndex: this.props.defaultActiveTabIndex || 0, + } + + handleTabClick (activeTabIndex) { + this.setState({ activeTabIndex }) + } + + renderTabs () { + const { tabsComponent } = this.props + + if (!tabsComponent) { + return + } + + const numberOfTabs = React.Children.count(tabsComponent.props.children) + + return React.Children.map(tabsComponent.props.children, (child, tabIndex) => { + return child && React.cloneElement(child, { + onClick: index => this.handleTabClick(index), + tabIndex, + isActive: numberOfTabs > 1 && tabIndex === this.state.activeTabIndex, + key: tabIndex, + className: 'page-container__tab', + activeClassName: 'page-container__tab--selected', + }) + }) + } + + renderActiveTabContent () { + const { tabsComponent } = this.props + const { children } = tabsComponent.props + const { activeTabIndex } = this.state + + return children[activeTabIndex] + ? children[activeTabIndex].props.children + : children.props.children + } + + renderContent () { + const { contentComponent, tabsComponent } = this.props + + if (contentComponent) { + return contentComponent + } else if (tabsComponent) { + return this.renderActiveTabContent() + } else { + return null + } + } render () { const { @@ -35,8 +87,6 @@ export default class PageContainer extends Component { onBackButtonClick, backButtonStyles, backButtonString, - ContentComponent, - contentComponentProps, onCancel, cancelText, onSubmit, @@ -54,9 +104,10 @@ export default class PageContainer extends Component { onBackButtonClick={onBackButtonClick} backButtonStyles={backButtonStyles} backButtonString={backButtonString} + tabs={this.renderTabs()} /> <div className="page-container__content"> - <ContentComponent { ...contentComponentProps } /> + { this.renderContent() } </div> <PageContainerFooter onCancel={onCancel} @@ -68,5 +119,4 @@ export default class PageContainer extends Component { </div> ) } - } diff --git a/ui/app/components/pages/add-token/add-token.component.js b/ui/app/components/pages/add-token/add-token.component.js index bcb93d401..3612e676c 100644 --- a/ui/app/components/pages/add-token/add-token.component.js +++ b/ui/app/components/pages/add-token/add-token.component.js @@ -1,14 +1,14 @@ import React, { Component } from 'react' -import classnames from 'classnames' import PropTypes from 'prop-types' import ethUtil from 'ethereumjs-util' import { checkExistingAddresses } from './util' import { tokenInfoGetter } from '../../../token-util' import { DEFAULT_ROUTE, CONFIRM_ADD_TOKEN_ROUTE } from '../../../routes' -import Button from '../../button' import TextField from '../../text-field' import TokenList from './token-list' import TokenSearch from './token-search' +import PageContainer from '../../page-container' +import { Tabs, Tab } from '../../tabs' const emptyAddr = '0x0000000000000000000000000000000000000000' const SEARCH_TAB = 'SEARCH' @@ -206,7 +206,7 @@ class AddToken extends Component { const validDecimals = customDecimals !== null && customDecimals !== '' && customDecimals >= 0 && - customDecimals < 36 + customDecimals <= 36 let customDecimalsError = null if (!validDecimals) { @@ -285,65 +285,33 @@ class AddToken extends Component { ) } + renderTabs () { + return ( + <Tabs> + <Tab name={this.context.t('search')}> + { this.renderSearchToken() } + </Tab> + <Tab name={this.context.t('customToken')}> + { this.renderCustomTokenForm() } + </Tab> + </Tabs> + ) + } + render () { - const { displayedTab } = this.state const { history, clearPendingTokens } = this.props return ( - <div className="page-container"> - <div className="page-container__header page-container__header--no-padding-bottom"> - <div className="page-container__title"> - { this.context.t('addTokens') } - </div> - <div className="page-container__tabs"> - <div - className={classnames('page-container__tab', { - 'page-container__tab--selected': displayedTab === SEARCH_TAB, - })} - onClick={() => this.setState({ displayedTab: SEARCH_TAB })} - > - { this.context.t('search') } - </div> - <div - className={classnames('page-container__tab', { - 'page-container__tab--selected': displayedTab === CUSTOM_TOKEN_TAB, - })} - onClick={() => this.setState({ displayedTab: CUSTOM_TOKEN_TAB })} - > - { this.context.t('customToken') } - </div> - </div> - </div> - <div className="page-container__content"> - { - displayedTab === CUSTOM_TOKEN_TAB - ? this.renderCustomTokenForm() - : this.renderSearchToken() - } - </div> - <div className="page-container__footer"> - <Button - type="default" - large - className="page-container__footer-button" - onClick={() => { - clearPendingTokens() - history.push(DEFAULT_ROUTE) - }} - > - { this.context.t('cancel') } - </Button> - <Button - type="primary" - large - className="page-container__footer-button" - onClick={() => this.handleNext()} - disabled={this.hasError() || !this.hasSelected()} - > - { this.context.t('next') } - </Button> - </div> - </div> + <PageContainer + title={this.context.t('addTokens')} + tabsComponent={this.renderTabs()} + onSubmit={() => this.handleNext()} + disabled={this.hasError() || !this.hasSelected()} + onCancel={() => { + clearPendingTokens() + history.push(DEFAULT_ROUTE) + }} + /> ) } } diff --git a/ui/app/components/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js b/ui/app/components/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js index 1611f817b..20f550927 100644 --- a/ui/app/components/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js +++ b/ui/app/components/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js @@ -15,7 +15,7 @@ export default class TokenListPlaceholder extends Component { </div> <a className="token-list-placeholder__link" - href="https://consensys.zendesk.com/hc/en-us/articles/360004135092" + href="https://metamask.zendesk.com/hc/en-us/articles/360015489031" target="_blank" rel="noopener noreferrer" > diff --git a/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js b/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js new file mode 100644 index 000000000..ee5d6fa64 --- /dev/null +++ b/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js @@ -0,0 +1,122 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { DEFAULT_ROUTE } from '../../../routes' +import Button from '../../button' +import Identicon from '../../../components/identicon' +import TokenBalance from '../../token-balance' + +export default class ConfirmAddSuggestedToken extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + clearPendingTokens: PropTypes.func, + addToken: PropTypes.func, + pendingTokens: PropTypes.object, + removeSuggestedTokens: PropTypes.func, + } + + componentDidMount () { + const { pendingTokens = {}, history } = this.props + + if (Object.keys(pendingTokens).length === 0) { + history.push(DEFAULT_ROUTE) + } + } + + getTokenName (name, symbol) { + return typeof name === 'undefined' + ? symbol + : `${name} (${symbol})` + } + + render () { + const { addToken, pendingTokens, removeSuggestedTokens, history } = this.props + const pendingTokenKey = Object.keys(pendingTokens)[0] + const pendingToken = pendingTokens[pendingTokenKey] + + return ( + <div className="page-container"> + <div className="page-container__header"> + <div className="page-container__title"> + { this.context.t('addSuggestedTokens') } + </div> + <div className="page-container__subtitle"> + { this.context.t('likeToAddTokens') } + </div> + </div> + <div className="page-container__content"> + <div className="confirm-add-token"> + <div className="confirm-add-token__header"> + <div className="confirm-add-token__token"> + { this.context.t('token') } + </div> + <div className="confirm-add-token__balance"> + { this.context.t('balance') } + </div> + </div> + <div className="confirm-add-token__token-list"> + { + Object.entries(pendingTokens) + .map(([ address, token ]) => { + const { name, symbol, image } = token + + return ( + <div + className="confirm-add-token__token-list-item" + key={address} + > + <div className="confirm-add-token__token confirm-add-token__data"> + <Identicon + className="confirm-add-token__token-icon" + diameter={48} + address={address} + image={image} + /> + <div className="confirm-add-token__name"> + { this.getTokenName(name, symbol) } + </div> + </div> + <div className="confirm-add-token__balance"> + <TokenBalance token={token} /> + </div> + </div> + ) + }) + } + </div> + </div> + </div> + <div className="page-container__footer"> + <header> + <Button + type="default" + large + className="page-container__footer-button" + onClick={() => { + removeSuggestedTokens() + .then(() => history.push(DEFAULT_ROUTE)) + }} + > + { this.context.t('cancel') } + </Button> + <Button + type="primary" + large + className="page-container__footer-button" + onClick={() => { + addToken(pendingToken) + .then(() => removeSuggestedTokens()) + .then(() => history.push(DEFAULT_ROUTE)) + }} + > + { this.context.t('addToken') } + </Button> + </header> + </div> + </div> + ) + } +} diff --git a/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js b/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js new file mode 100644 index 000000000..1f2737e52 --- /dev/null +++ b/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import ConfirmAddSuggestedToken from './confirm-add-suggested-token.component' +import { withRouter } from 'react-router-dom' + +const extend = require('xtend') + +const { addToken, removeSuggestedTokens } = require('../../../actions') + +const mapStateToProps = ({ metamask }) => { + const { pendingTokens, suggestedTokens } = metamask + const params = extend(pendingTokens, suggestedTokens) + + return { + pendingTokens: params, + } +} + +const mapDispatchToProps = dispatch => { + return { + addToken: ({address, symbol, decimals, image}) => dispatch(addToken(address, symbol, decimals, image)), + removeSuggestedTokens: () => dispatch(removeSuggestedTokens()), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(ConfirmAddSuggestedToken) diff --git a/ui/app/components/pages/confirm-add-suggested-token/index.js b/ui/app/components/pages/confirm-add-suggested-token/index.js new file mode 100644 index 000000000..2ca56b43c --- /dev/null +++ b/ui/app/components/pages/confirm-add-suggested-token/index.js @@ -0,0 +1,2 @@ +import ConfirmAddSuggestedToken from './confirm-add-suggested-token.container' +module.exports = ConfirmAddSuggestedToken diff --git a/ui/app/components/pages/confirm-add-token/confirm-add-token.component.js b/ui/app/components/pages/confirm-add-token/confirm-add-token.component.js index 65d654b92..d3fec79d7 100644 --- a/ui/app/components/pages/confirm-add-token/confirm-add-token.component.js +++ b/ui/app/components/pages/confirm-add-token/confirm-add-token.component.js @@ -2,8 +2,8 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import { DEFAULT_ROUTE, ADD_TOKEN_ROUTE } from '../../../routes' import Button from '../../button' -import Identicon from '../../../components/identicon' -import TokenBalance from './token-balance' +import Identicon from '../../identicon' +import TokenBalance from '../../token-balance' export default class ConfirmAddToken extends Component { static contextTypes = { @@ -86,28 +86,30 @@ export default class ConfirmAddToken extends Component { </div> </div> <div className="page-container__footer"> - <Button - type="default" - large - className="page-container__footer-button" - onClick={() => history.push(ADD_TOKEN_ROUTE)} - > - { this.context.t('back') } - </Button> - <Button - type="primary" - large - className="page-container__footer-button" - onClick={() => { - addTokens(pendingTokens) - .then(() => { - clearPendingTokens() - history.push(DEFAULT_ROUTE) - }) - }} - > - { this.context.t('addTokens') } - </Button> + <header> + <Button + type="default" + large + className="page-container__footer-button" + onClick={() => history.push(ADD_TOKEN_ROUTE)} + > + { this.context.t('back') } + </Button> + <Button + type="primary" + large + className="page-container__footer-button" + onClick={() => { + addTokens(pendingTokens) + .then(() => { + clearPendingTokens() + history.push(DEFAULT_ROUTE) + }) + }} + > + { this.context.t('addTokens') } + </Button> + </header> </div> </div> ) diff --git a/ui/app/components/pages/confirm-add-token/token-balance/index.js b/ui/app/components/pages/confirm-add-token/token-balance/index.js deleted file mode 100644 index 6fb5c8223..000000000 --- a/ui/app/components/pages/confirm-add-token/token-balance/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import TokenBalance from './token-balance.container' -module.exports = TokenBalance diff --git a/ui/app/components/pages/confirm-add-token/token-balance/token-balance.component.js b/ui/app/components/pages/confirm-add-token/token-balance/token-balance.component.js deleted file mode 100644 index 976788d4c..000000000 --- a/ui/app/components/pages/confirm-add-token/token-balance/token-balance.component.js +++ /dev/null @@ -1,16 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' - -export default class TokenBalance extends Component { - static propTypes = { - string: PropTypes.string, - symbol: PropTypes.string, - error: PropTypes.string, - } - - render () { - return ( - <div className="hide-text-overflow">{ this.props.string }</div> - ) - } -} 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 index 961aa304e..707dad62d 100644 --- 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 @@ -8,6 +8,7 @@ import { INSUFFICIENT_FUNDS_ERROR_KEY, TRANSACTION_ERROR_KEY, } from '../../../constants/error-keys' +import { CONFIRMED_STATUS, DROPPED_STATUS } from '../../../constants/transactions' export default class ConfirmTransactionBase extends Component { static contextTypes = { @@ -21,6 +22,7 @@ export default class ConfirmTransactionBase extends Component { // Redux props balance: PropTypes.string, cancelTransaction: PropTypes.func, + cancelAllTransactions: PropTypes.func, clearConfirmTransaction: PropTypes.func, clearSend: PropTypes.func, conversionRate: PropTypes.number, @@ -38,15 +40,18 @@ export default class ConfirmTransactionBase extends Component { isTxReprice: PropTypes.bool, methodData: PropTypes.object, nonce: PropTypes.string, + assetImage: PropTypes.string, sendTransaction: PropTypes.func, showCustomizeGasModal: PropTypes.func, showTransactionConfirmedModal: PropTypes.func, + showRejectTransactionsConfirmationModal: PropTypes.func, toAddress: PropTypes.string, tokenData: PropTypes.object, tokenProps: PropTypes.object, toName: PropTypes.string, transactionStatus: PropTypes.string, txData: PropTypes.object, + unapprovedTxCount: PropTypes.number, // Component props action: PropTypes.string, contentComponent: PropTypes.node, @@ -73,6 +78,7 @@ export default class ConfirmTransactionBase extends Component { state = { submitting: false, + submitError: null, } componentDidUpdate () { @@ -83,9 +89,9 @@ export default class ConfirmTransactionBase extends Component { clearConfirmTransaction, } = this.props - if (transactionStatus === 'dropped') { + if (transactionStatus === DROPPED_STATUS || transactionStatus === CONFIRMED_STATUS) { showTransactionConfirmedModal({ - onHide: () => { + onSubmit: () => { clearConfirmTransaction() history.push(DEFAULT_ROUTE) }, @@ -246,6 +252,25 @@ export default class ConfirmTransactionBase extends Component { onEdit({ txData, tokenData, tokenProps }) } + handleCancelAll () { + const { + cancelAllTransactions, + clearConfirmTransaction, + history, + showRejectTransactionsConfirmationModal, + unapprovedTxCount, + } = this.props + + showRejectTransactionsConfirmationModal({ + unapprovedTxCount, + async onSubmit () { + await cancelAllTransactions() + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }, + }) + } + handleCancel () { const { onCancel, txData, cancelTransaction, history, clearConfirmTransaction } = this.props @@ -268,7 +293,7 @@ export default class ConfirmTransactionBase extends Component { return } - this.setState({ submitting: true }) + this.setState({ submitting: true, submitError: null }) if (onSubmit) { Promise.resolve(onSubmit(txData)) @@ -280,7 +305,9 @@ export default class ConfirmTransactionBase extends Component { this.setState({ submitting: false }) history.push(DEFAULT_ROUTE) }) - .catch(() => this.setState({ submitting: false })) + .catch(error => { + this.setState({ submitting: false, submitError: error.message }) + }) } } @@ -307,9 +334,11 @@ export default class ConfirmTransactionBase extends Component { contentComponent, onEdit, nonce, + assetImage, warning, + unapprovedTxCount, } = this.props - const { submitting } = this.state + const { submitting, submitError } = this.state const { name } = methodData const fiatConvertedAmount = formatCurrency(fiatTransactionAmount, currentCurrency) @@ -331,12 +360,15 @@ export default class ConfirmTransactionBase extends Component { dataComponent={this.renderData()} contentComponent={contentComponent} nonce={nonce} + unapprovedTxCount={unapprovedTxCount} + assetImage={assetImage} identiconAddress={identiconAddress} - errorMessage={errorMessage} + errorMessage={errorMessage || submitError} errorKey={propsErrorKey || errorKey} warning={warning} disabled={!propsValid || !valid || submitting} onEdit={() => this.handleEdit()} + onCancelAll={() => this.handleCancelAll()} 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 index 0c0deff18..b34067686 100644 --- 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 @@ -8,7 +8,7 @@ import { clearConfirmTransaction, updateGasAndCalculate, } from '../../../ducks/confirm-transaction.duck' -import { clearSend, cancelTx, updateAndApproveTx, showModal } from '../../../actions' +import { clearSend, cancelTx, cancelTxs, updateAndApproveTx, showModal } from '../../../actions' import { INSUFFICIENT_FUNDS_ERROR_KEY, GAS_LIMIT_TOO_LOW_ERROR_KEY, @@ -17,7 +17,7 @@ import { getHexGasTotal } from '../../../helpers/confirm-transaction/util' import { isBalanceSufficient } from '../../send/send.utils' import { conversionGreaterThan } from '../../../conversion-util' import { MIN_GAS_LIMIT_DEC } from '../../send/send.constants' -import { addressSlicer } from '../../../util' +import { addressSlicer, valuesFor } from '../../../util' const casedContractMap = Object.keys(contractMap).reduce((acc, base) => { return { @@ -52,8 +52,11 @@ const mapStateToProps = (state, props) => { accounts, selectedAddress, selectedAddressTxList, + assetImages, + network, + unapprovedTxs, } = metamask - + const assetImage = assetImages[txParamsToAddress] const { balance } = accounts[selectedAddress] const { name: fromName } = identities[selectedAddress] const toAddress = propsToAddress || txParamsToAddress @@ -66,6 +69,12 @@ const mapStateToProps = (state, props) => { const transaction = R.find(({ id }) => id === transactionId)(selectedAddressTxList) const transactionStatus = transaction ? transaction.status : '' + const currentNetworkUnapprovedTxs = R.filter( + ({ metamaskNetworkId }) => metamaskNetworkId === network, + valuesFor(unapprovedTxs), + ) + const unapprovedTxCount = currentNetworkUnapprovedTxs.length + return { balance, fromAddress, @@ -88,6 +97,9 @@ const mapStateToProps = (state, props) => { conversionRate, transactionStatus, nonce, + assetImage, + unapprovedTxs, + unapprovedTxCount, } } @@ -95,8 +107,8 @@ const mapDispatchToProps = dispatch => { return { clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), clearSend: () => dispatch(clearSend()), - showTransactionConfirmedModal: ({ onHide }) => { - return dispatch(showModal({ name: 'TRANSACTION_CONFIRMED', onHide })) + showTransactionConfirmedModal: ({ onSubmit }) => { + return dispatch(showModal({ name: 'TRANSACTION_CONFIRMED', onSubmit })) }, showCustomizeGasModal: ({ txData, onSubmit, validate }) => { return dispatch(showModal({ name: 'CONFIRM_CUSTOMIZE_GAS', txData, onSubmit, validate })) @@ -104,7 +116,11 @@ const mapDispatchToProps = dispatch => { updateGasAndCalculate: ({ gasLimit, gasPrice }) => { return dispatch(updateGasAndCalculate({ gasLimit, gasPrice })) }, + showRejectTransactionsConfirmationModal: ({ onSubmit, unapprovedTxCount }) => { + return dispatch(showModal({ name: 'REJECT_TRANSACTIONS', onSubmit, unapprovedTxCount })) + }, cancelTransaction: ({ id }) => dispatch(cancelTx({ id })), + cancelAllTransactions: (txList) => dispatch(cancelTxs(txList)), sendTransaction: txData => dispatch(updateAndApproveTx(txData)), } } @@ -154,8 +170,9 @@ const getValidateEditGas = ({ balance, conversionRate, txData }) => { } const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { balance, conversionRate, txData } = stateProps + const { balance, conversionRate, txData, unapprovedTxs } = stateProps const { + cancelAllTransactions: dispatchCancelAllTransactions, showCustomizeGasModal: dispatchShowCustomizeGasModal, updateGasAndCalculate: dispatchUpdateGasAndCalculate, ...otherDispatchProps @@ -172,6 +189,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { onSubmit: txData => dispatchUpdateGasAndCalculate(txData), validate: validateEditGas, }), + cancelAllTransactions: () => dispatchCancelAllTransactions(valuesFor(unapprovedTxs)), } } 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 index 0280f73c6..2c44b6094 100644 --- 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 @@ -12,25 +12,27 @@ import { CONFIRM_TOKEN_METHOD_PATH, SIGNATURE_REQUEST_PATH, } from '../../../routes' -import { isConfirmDeployContract } from './confirm-transaction-switch.util' +import { isConfirmDeployContract } from '../../../helpers/transactions.util' import { TOKEN_METHOD_TRANSFER, TOKEN_METHOD_APPROVE, TOKEN_METHOD_TRANSFER_FROM, -} from './confirm-transaction-switch.constants' +} from '../../../constants/transactions' export default class ConfirmTransactionSwitch extends Component { static propTypes = { txData: PropTypes.object, methodData: PropTypes.object, - fetchingMethodData: PropTypes.bool, + fetchingData: PropTypes.bool, + isEtherTransaction: PropTypes.bool, } redirectToTransaction () { const { txData, methodData: { name }, - fetchingMethodData, + fetchingData, + isEtherTransaction, } = this.props const { id, txParams: { data } = {} } = txData @@ -39,10 +41,15 @@ export default class ConfirmTransactionSwitch extends Component { return <Redirect to={{ pathname }} /> } - if (fetchingMethodData) { + if (fetchingData) { return <Loading /> } + if (isEtherTransaction) { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SEND_ETHER_PATH}` + return <Redirect to={{ pathname }} /> + } + if (data) { const methodName = name && name.toLowerCase() 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 deleted file mode 100644 index 9db4a2f96..000000000 --- a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.constants.js +++ /dev/null @@ -1,3 +0,0 @@ -export const TOKEN_METHOD_TRANSFER = 'transfer' -export const TOKEN_METHOD_APPROVE = 'approve' -export const TOKEN_METHOD_TRANSFER_FROM = 'transferfrom' diff --git a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.container.js b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.container.js index 3d7fc78cc..7f2c36af2 100644 --- 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 @@ -6,14 +6,16 @@ const mapStateToProps = state => { confirmTransaction: { txData, methodData, - fetchingMethodData, + fetchingData, + toSmartContract, }, } = state return { txData, methodData, - fetchingMethodData, + fetchingData, + isEtherTransaction: !toSmartContract, } } diff --git a/ui/app/components/pages/create-account/connect-hardware/account-list.js b/ui/app/components/pages/create-account/connect-hardware/account-list.js index 488a189ea..2767b2e1f 100644 --- a/ui/app/components/pages/create-account/connect-hardware/account-list.js +++ b/ui/app/components/pages/create-account/connect-hardware/account-list.js @@ -3,6 +3,7 @@ const PropTypes = require('prop-types') const h = require('react-hyperscript') const genAccountLink = require('../../../../../lib/account-link.js') const Select = require('react-select').default +import Button from '../../../button' class AccountList extends Component { constructor (props, context) { @@ -143,22 +144,20 @@ class AccountList extends Component { } return h('div.new-account-connect-form__buttons', {}, [ - h( - 'button.btn-default.btn--large.new-account-connect-form__button', - { - onClick: this.props.onCancel.bind(this), - }, - [this.context.t('cancel')] - ), - - h( - `button.btn-primary.btn--large.new-account-connect-form__button.unlock ${disabled ? '.btn-primary--disabled' : ''}`, - { - onClick: this.props.onUnlockAccount.bind(this, this.props.device), - ...buttonProps, - }, - [this.context.t('unlock')] - ), + h(Button, { + type: 'default', + large: true, + className: 'new-account-connect-form__button', + onClick: this.props.onCancel.bind(this), + }, [this.context.t('cancel')]), + + h(Button, { + type: 'primary', + large: true, + className: 'new-account-connect-form__button unlock', + disabled, + onClick: this.props.onUnlockAccount.bind(this, this.props.device), + }, [this.context.t('unlock')]), ]) } diff --git a/ui/app/components/pages/create-account/connect-hardware/connect-screen.js b/ui/app/components/pages/create-account/connect-hardware/connect-screen.js index b3dfa4ee2..d3abf3119 100644 --- a/ui/app/components/pages/create-account/connect-hardware/connect-screen.js +++ b/ui/app/components/pages/create-account/connect-hardware/connect-screen.js @@ -1,6 +1,7 @@ const { Component } = require('react') const PropTypes = require('prop-types') const h = require('react-hyperscript') +import Button from '../../../button' class ConnectScreen extends Component { constructor (props, context) { @@ -60,13 +61,13 @@ class ConnectScreen extends Component { h('h3.hw-connect__title', {}, this.context.t('browserNotSupported')), h('p.hw-connect__msg', {}, this.context.t('chromeRequiredForHardwareWallets')), ]), - h( - 'button.btn-primary.btn--large', - { - onClick: () => global.platform.openWindow({ - url: 'https://google.com/chrome', - }), - }, + h(Button, { + type: 'primary', + large: true, + onClick: () => global.platform.openWindow({ + url: 'https://google.com/chrome', + }), + }, this.context.t('downloadGoogleChrome') ), ]) diff --git a/ui/app/components/pages/create-account/import-account/index.js b/ui/app/components/pages/create-account/import-account/index.js index e2e973af9..48d8f8838 100644 --- a/ui/app/components/pages/create-account/import-account/index.js +++ b/ui/app/components/pages/create-account/import-account/index.js @@ -46,7 +46,7 @@ AccountImportSubview.prototype.render = function () { }, onClick: () => { global.platform.openWindow({ - url: 'https://consensys.zendesk.com/hc/en-us/articles/360004180111-What-are-imported-accounts-New-UI', + url: 'https://metamask.zendesk.com/hc/en-us/articles/360015289932', }) }, }, this.context.t('here')), diff --git a/ui/app/components/pages/create-account/import-account/json.js b/ui/app/components/pages/create-account/import-account/json.js index dd57256a3..90279bbbd 100644 --- a/ui/app/components/pages/create-account/import-account/json.js +++ b/ui/app/components/pages/create-account/import-account/json.js @@ -8,6 +8,7 @@ const actions = require('../../../../actions') const FileInput = require('react-simple-file-input').default const { DEFAULT_ROUTE } = require('../../../../routes') const HELP_LINK = 'https://support.metamask.io/kb/article/7-importing-accounts' +import Button from '../../../button' class JsonImportSubview extends Component { constructor (props) { @@ -51,17 +52,19 @@ class JsonImportSubview extends Component { h('div.new-account-create-form__buttons', {}, [ - h('button.btn-default.new-account-create-form__button', { + h(Button, { + type: 'default', + large: true, + className: 'new-account-create-form__button', onClick: () => this.props.history.push(DEFAULT_ROUTE), - }, [ - this.context.t('cancel'), - ]), + }, [this.context.t('cancel')]), - h('button.btn-primary.new-account-create-form__button', { + h(Button, { + type: 'primary', + large: true, + className: 'new-account-create-form__button', onClick: () => this.createNewKeychain(), - }, [ - this.context.t('import'), - ]), + }, [this.context.t('import')]), ]), @@ -82,7 +85,7 @@ class JsonImportSubview extends Component { } createNewKeychain () { - const { firstAddress, displayWarning, importNewJsonAccount, setSelectedAddress } = this.props + const { firstAddress, displayWarning, importNewJsonAccount, setSelectedAddress, history } = this.props const state = this.state if (!state) { diff --git a/ui/app/components/pages/create-account/import-account/private-key.js b/ui/app/components/pages/create-account/import-account/private-key.js index 1db999f2f..8db1bfbdd 100644 --- a/ui/app/components/pages/create-account/import-account/private-key.js +++ b/ui/app/components/pages/create-account/import-account/private-key.js @@ -7,6 +7,7 @@ const PropTypes = require('prop-types') const connect = require('react-redux').connect const actions = require('../../../../actions') const { DEFAULT_ROUTE } = require('../../../../routes') +import Button from '../../../button' PrivateKeyImportView.contextTypes = { t: PropTypes.func, @@ -61,20 +62,22 @@ PrivateKeyImportView.prototype.render = function () { h('div.new-account-import-form__buttons', {}, [ - h('button.btn-default.btn--large.new-account-create-form__button', { + h(Button, { + type: 'default', + large: true, + className: 'new-account-create-form__button', onClick: () => { displayWarning(null) this.props.history.push(DEFAULT_ROUTE) }, - }, [ - this.context.t('cancel'), - ]), + }, [this.context.t('cancel')]), - h('button.btn-primary.btn--large.new-account-create-form__button', { + h(Button, { + type: 'primary', + large: true, + className: 'new-account-create-form__button', onClick: () => this.createNewKeychain(), - }, [ - this.context.t('import'), - ]), + }, [this.context.t('import')]), ]), diff --git a/ui/app/components/pages/create-account/new-account.js b/ui/app/components/pages/create-account/new-account.js index 402b8f03b..94a5fa487 100644 --- a/ui/app/components/pages/create-account/new-account.js +++ b/ui/app/components/pages/create-account/new-account.js @@ -4,6 +4,7 @@ const h = require('react-hyperscript') const connect = require('react-redux').connect const actions = require('../../../actions') const { DEFAULT_ROUTE } = require('../../../routes') +import Button from '../../button' class NewAccountCreateForm extends Component { constructor (props, context) { @@ -38,20 +39,22 @@ class NewAccountCreateForm extends Component { h('div.new-account-create-form__buttons', {}, [ - h('button.btn-default.btn--large.new-account-create-form__button', { + h(Button, { + type: 'default', + large: true, + className: 'new-account-create-form__button', onClick: () => history.push(DEFAULT_ROUTE), - }, [ - this.context.t('cancel'), - ]), + }, [this.context.t('cancel')]), - h('button.btn-primary.btn--large.new-account-create-form__button', { + h(Button, { + type: 'primary', + large: true, + className: 'new-account-create-form__button', onClick: () => { createAccount(newAccountName || defaultAccountName) .then(() => history.push(DEFAULT_ROUTE)) }, - }, [ - this.context.t('create'), - ]), + }, [this.context.t('create')]), ]), diff --git a/ui/app/components/pages/home.js b/ui/app/components/pages/home.js deleted file mode 100644 index 5e3fdc9af..000000000 --- a/ui/app/components/pages/home.js +++ /dev/null @@ -1,239 +0,0 @@ -const { Component } = require('react') -const { connect } = require('react-redux') -const PropTypes = require('prop-types') -const { Redirect, withRouter } = require('react-router-dom') -const { compose } = require('recompose') -const h = require('react-hyperscript') -const actions = require('../../actions') -const log = require('loglevel') - -// init -const NewKeyChainScreen = require('../../new-keychain') -// mascara -const MascaraBuyEtherScreen = require('../../../../mascara/src/app/first-time/buy-ether-screen').default - -// accounts -const MainContainer = require('../../main-container') - -// other views -const BuyView = require('../../components/buy-button-subview') -const QrView = require('../../components/qr-code') - -// Routes -const { - INITIALIZE_BACKUP_PHRASE_ROUTE, - RESTORE_VAULT_ROUTE, - CONFIRM_TRANSACTION_ROUTE, - NOTICE_ROUTE, -} = require('../../routes') - -const { unconfirmedTransactionsCountSelector } = require('../../selectors/confirm-transaction') - -class Home extends Component { - componentDidMount () { - const { - history, - unconfirmedTransactionsCount = 0, - } = this.props - - // unapprovedTxs and unapproved messages - if (unconfirmedTransactionsCount > 0) { - history.push(CONFIRM_TRANSACTION_ROUTE) - } - } - - render () { - log.debug('rendering primary') - const { - noActiveNotices, - lostAccounts, - forgottenPassword, - currentView, - activeAddress, - seedWords, - } = this.props - - // notices - if (!noActiveNotices || (lostAccounts && lostAccounts.length > 0)) { - return h(Redirect, { - to: { - pathname: NOTICE_ROUTE, - }, - }) - } - - // seed words - if (seedWords) { - log.debug('rendering seed words') - return h(Redirect, { - to: { - pathname: INITIALIZE_BACKUP_PHRASE_ROUTE, - }, - }) - } - - if (forgottenPassword) { - log.debug('rendering restore vault screen') - return h(Redirect, { - to: { - pathname: RESTORE_VAULT_ROUTE, - }, - }) - } - - // show current view - switch (currentView.name) { - - case 'accountDetail': - log.debug('rendering main container') - return h(MainContainer, {key: 'account-detail'}) - - case 'newKeychain': - log.debug('rendering new keychain screen') - return h(NewKeyChainScreen, {key: 'new-keychain'}) - - case 'buyEth': - log.debug('rendering buy ether screen') - return h(BuyView, {key: 'buyEthView'}) - - case 'onboardingBuyEth': - log.debug('rendering onboarding buy ether screen') - return h(MascaraBuyEtherScreen, {key: 'buyEthView'}) - - case 'qr': - log.debug('rendering show qr screen') - return h('div', { - style: { - position: 'absolute', - height: '100%', - top: '0px', - left: '0px', - }, - }, [ - h('i.fa.fa-arrow-left.fa-lg.cursor-pointer.color-orange', { - onClick: () => this.props.dispatch(actions.backToAccountDetail(activeAddress)), - style: { - marginLeft: '10px', - marginTop: '50px', - }, - }), - h('div', { - style: { - position: 'absolute', - left: '44px', - width: '285px', - }, - }, [ - h(QrView, {key: 'qr'}), - ]), - ]) - - default: - log.debug('rendering default, account detail screen') - return h(MainContainer, {key: 'account-detail'}) - } - } -} - -Home.propTypes = { - currentCurrency: PropTypes.string, - isLoading: PropTypes.bool, - loadingMessage: PropTypes.string, - network: PropTypes.string, - provider: PropTypes.object, - frequentRpcList: PropTypes.array, - currentView: PropTypes.object, - sidebarOpen: PropTypes.bool, - isMascara: PropTypes.bool, - isOnboarding: PropTypes.bool, - isUnlocked: PropTypes.bool, - networkDropdownOpen: PropTypes.bool, - history: PropTypes.object, - dispatch: PropTypes.func, - selectedAddress: PropTypes.string, - noActiveNotices: PropTypes.bool, - lostAccounts: PropTypes.array, - isInitialized: PropTypes.bool, - forgottenPassword: PropTypes.bool, - activeAddress: PropTypes.string, - unapprovedTxs: PropTypes.object, - seedWords: PropTypes.string, - unapprovedMsgCount: PropTypes.number, - unapprovedPersonalMsgCount: PropTypes.number, - unapprovedTypedMessagesCount: PropTypes.number, - welcomeScreenSeen: PropTypes.bool, - isPopup: PropTypes.bool, - isMouseUser: PropTypes.bool, - t: PropTypes.func, - unconfirmedTransactionsCount: PropTypes.number, -} - -function mapStateToProps (state) { - const { appState, metamask } = state - const { - networkDropdownOpen, - sidebarOpen, - isLoading, - loadingMessage, - } = appState - - const { - accounts, - address, - isInitialized, - noActiveNotices, - seedWords, - unapprovedTxs, - nextUnreadNotice, - lostAccounts, - unapprovedMsgCount, - unapprovedPersonalMsgCount, - unapprovedTypedMessagesCount, - } = metamask - const selected = address || Object.keys(accounts)[0] - - return { - // state from plugin - networkDropdownOpen, - sidebarOpen, - isLoading, - loadingMessage, - noActiveNotices, - isInitialized, - isUnlocked: state.metamask.isUnlocked, - selectedAddress: state.metamask.selectedAddress, - currentView: state.appState.currentView, - activeAddress: state.appState.activeAddress, - transForward: state.appState.transForward, - isMascara: state.metamask.isMascara, - isOnboarding: Boolean(!noActiveNotices || seedWords || !isInitialized), - isPopup: state.metamask.isPopup, - seedWords: state.metamask.seedWords, - unapprovedTxs, - unapprovedMsgs: state.metamask.unapprovedMsgs, - unapprovedMsgCount, - unapprovedPersonalMsgCount, - unapprovedTypedMessagesCount, - menuOpen: state.appState.menuOpen, - network: state.metamask.network, - provider: state.metamask.provider, - forgottenPassword: state.appState.forgottenPassword, - nextUnreadNotice, - lostAccounts, - frequentRpcList: state.metamask.frequentRpcList || [], - currentCurrency: state.metamask.currentCurrency, - isMouseUser: state.appState.isMouseUser, - isRevealingSeedWords: state.metamask.isRevealingSeedWords, - Qr: state.appState.Qr, - welcomeScreenSeen: state.metamask.welcomeScreenSeen, - - // state needed to get account dropdown temporarily rendering from app bar - selected, - unconfirmedTransactionsCount: unconfirmedTransactionsCountSelector(state), - } -} - -module.exports = compose( - withRouter, - connect(mapStateToProps) -)(Home) diff --git a/ui/app/components/pages/home/home.component.js b/ui/app/components/pages/home/home.component.js new file mode 100644 index 000000000..d3c71c4f6 --- /dev/null +++ b/ui/app/components/pages/home/home.component.js @@ -0,0 +1,77 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Media from 'react-media' +import { Redirect } from 'react-router-dom' +import WalletView from '../../wallet-view' +import TransactionView from '../../transaction-view' +import { + INITIALIZE_BACKUP_PHRASE_ROUTE, + RESTORE_VAULT_ROUTE, + CONFIRM_TRANSACTION_ROUTE, + NOTICE_ROUTE, + CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE, +} from '../../../routes' + +export default class Home extends PureComponent { + static propTypes = { + history: PropTypes.object, + noActiveNotices: PropTypes.bool, + lostAccounts: PropTypes.array, + forgottenPassword: PropTypes.bool, + seedWords: PropTypes.string, + suggestedTokens: PropTypes.object, + unconfirmedTransactionsCount: PropTypes.number, + } + + componentDidMount () { + const { + history, + suggestedTokens = {}, + unconfirmedTransactionsCount = 0, + } = this.props + + // suggested new tokens + if (Object.keys(suggestedTokens).length > 0) { + history.push(CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE) + } + + if (unconfirmedTransactionsCount > 0) { + history.push(CONFIRM_TRANSACTION_ROUTE) + } + } + + render () { + const { + noActiveNotices, + lostAccounts, + forgottenPassword, + seedWords, + } = this.props + + // notices + if (!noActiveNotices || (lostAccounts && lostAccounts.length > 0)) { + return <Redirect to={{ pathname: NOTICE_ROUTE }} /> + } + + // seed words + if (seedWords) { + return <Redirect to={{ pathname: INITIALIZE_BACKUP_PHRASE_ROUTE }}/> + } + + if (forgottenPassword) { + return <Redirect to={{ pathname: RESTORE_VAULT_ROUTE }} /> + } + + return ( + <div className="main-container"> + <div className="account-and-transaction-details"> + <Media + query="(min-width: 576px)" + render={() => <WalletView />} + /> + <TransactionView /> + </div> + </div> + ) + } +} diff --git a/ui/app/components/pages/home/home.container.js b/ui/app/components/pages/home/home.container.js new file mode 100644 index 000000000..58001df6b --- /dev/null +++ b/ui/app/components/pages/home/home.container.js @@ -0,0 +1,30 @@ +import Home from './home.component' +import { compose } from 'recompose' +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { unconfirmedTransactionsCountSelector } from '../../../selectors/confirm-transaction' + +const mapStateToProps = state => { + const { metamask, appState } = state + const { + noActiveNotices, + lostAccounts, + seedWords, + suggestedTokens, + } = metamask + const { forgottenPassword } = appState + + return { + noActiveNotices, + lostAccounts, + forgottenPassword, + seedWords, + suggestedTokens, + unconfirmedTransactionsCount: unconfirmedTransactionsCountSelector(state), + } +} + +export default compose( + withRouter, + connect(mapStateToProps) +)(Home) diff --git a/ui/app/components/pages/home/index.js b/ui/app/components/pages/home/index.js new file mode 100644 index 000000000..4474ba5b8 --- /dev/null +++ b/ui/app/components/pages/home/index.js @@ -0,0 +1 @@ +export { default } from './home.container' diff --git a/ui/app/components/pages/index.scss b/ui/app/components/pages/index.scss index b15c59863..6551278f5 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 './settings/index'; diff --git a/ui/app/components/pages/keychains/reveal-seed.js b/ui/app/components/pages/keychains/reveal-seed.js index 7d7d3f462..32557066f 100644 --- a/ui/app/components/pages/keychains/reveal-seed.js +++ b/ui/app/components/pages/keychains/reveal-seed.js @@ -8,6 +8,8 @@ const { requestRevealSeedWords } = require('../../../actions') const { DEFAULT_ROUTE } = require('../../../routes') const ExportTextContainer = require('../../export-text-container') +import Button from '../../button' + const PASSWORD_PROMPT_SCREEN = 'PASSWORD_PROMPT_SCREEN' const REVEAL_SEED_SCREEN = 'REVEAL_SEED_SCREEN' @@ -106,13 +108,21 @@ class RevealSeedPage extends Component { renderPasswordPromptFooter () { return ( h('.page-container__footer', [ - h('button.btn-default.btn--large.page-container__footer-button', { - onClick: () => this.props.history.push(DEFAULT_ROUTE), - }, this.context.t('cancel')), - h('button.btn-primary.btn--large.page-container__footer-button', { - onClick: event => this.handleSubmit(event), - disabled: this.state.password === '', - }, this.context.t('next')), + h('header', [ + h(Button, { + type: 'default', + large: true, + className: 'page-container__footer-button', + onClick: () => this.props.history.push(DEFAULT_ROUTE), + }, this.context.t('cancel')), + h(Button, { + type: 'primary', + large: true, + className: 'page-container__footer-button', + onClick: event => this.handleSubmit(event), + disabled: this.state.password === '', + }, this.context.t('next')), + ]), ]) ) } @@ -120,7 +130,10 @@ class RevealSeedPage extends Component { renderRevealSeedFooter () { return ( h('.page-container__footer', [ - h('button.btn-default.btn--large.page-container__footer-button', { + h(Button, { + type: 'default', + large: true, + className: 'page-container__footer-button', onClick: () => this.props.history.push(DEFAULT_ROUTE), }, this.context.t('close')), ]) diff --git a/ui/app/components/pages/settings/index.js b/ui/app/components/pages/settings/index.js index aee17e0e8..44a9ffa63 100644 --- a/ui/app/components/pages/settings/index.js +++ b/ui/app/components/pages/settings/index.js @@ -1,64 +1 @@ -const { Component } = require('react') -const { Switch, Route, matchPath } = require('react-router-dom') -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const TabBar = require('../../tab-bar') -const Settings = require('./settings') -const Info = require('./info') -const { DEFAULT_ROUTE, SETTINGS_ROUTE, INFO_ROUTE } = require('../../../routes') - -class Config extends Component { - renderTabs () { - const { history, location } = this.props - - return h('div.settings__tabs', [ - h(TabBar, { - tabs: [ - { content: this.context.t('settings'), key: SETTINGS_ROUTE }, - { content: this.context.t('info'), key: INFO_ROUTE }, - ], - isActive: key => matchPath(location.pathname, { path: key, exact: true }), - onSelect: key => history.push(key), - }), - ]) - } - - render () { - const { history } = this.props - - return ( - h('.main-container.settings', {}, [ - h('.settings__header', [ - h('div.settings__close-button', { - onClick: () => history.push(DEFAULT_ROUTE), - }), - this.renderTabs(), - ]), - h(Switch, [ - h(Route, { - exact: true, - path: INFO_ROUTE, - component: Info, - }), - h(Route, { - exact: true, - path: SETTINGS_ROUTE, - component: Settings, - }), - ]), - ]) - ) - } -} - -Config.propTypes = { - location: PropTypes.object, - history: PropTypes.object, - t: PropTypes.func, -} - -Config.contextTypes = { - t: PropTypes.func, -} - -module.exports = Config +export { default } from './settings.component' diff --git a/ui/app/components/pages/settings/index.scss b/ui/app/components/pages/settings/index.scss new file mode 100644 index 000000000..138ebcfc5 --- /dev/null +++ b/ui/app/components/pages/settings/index.scss @@ -0,0 +1,80 @@ +@import './info-tab/index'; + +@import './settings-tab/index'; + +.settings-page { + position: relative; + background: $white; + display: flex; + flex-flow: column nowrap; + + &__header { + padding: 25px 25px 0; + } + + &__close-button::after { + content: '\00D7'; + font-size: 40px; + color: $dusty-gray; + position: absolute; + top: 25px; + right: 30px; + cursor: pointer; + } + + &__content { + padding: 25px; + height: auto; + overflow: auto; + } + + &__content-row { + display: flex; + flex-direction: row; + padding: 10px 0 20px; + + @media screen and (max-width: 575px) { + flex-direction: column; + padding: 10px 0; + } + } + + &__content-item { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + padding: 0 5px; + min-height: 71px; + + @media screen and (max-width: 575px) { + height: initial; + padding: 5px 0; + } + + &--without-height { + height: initial; + } + } + + &__content-label { + text-transform: capitalize; + } + + &__content-description { + font-size: 14px; + color: $dusty-gray; + padding-top: 5px; + } + + &__content-item-col { + max-width: 300px; + display: flex; + flex-direction: column; + + @media screen and (max-width: 575px) { + max-width: 100%; + width: 100%; + } + } +} diff --git a/ui/app/components/pages/settings/info-tab/index.js b/ui/app/components/pages/settings/info-tab/index.js new file mode 100644 index 000000000..7556a258d --- /dev/null +++ b/ui/app/components/pages/settings/info-tab/index.js @@ -0,0 +1 @@ +export { default } from './info-tab.component' diff --git a/ui/app/components/pages/settings/info-tab/index.scss b/ui/app/components/pages/settings/info-tab/index.scss new file mode 100644 index 000000000..43ad6f652 --- /dev/null +++ b/ui/app/components/pages/settings/info-tab/index.scss @@ -0,0 +1,56 @@ +.info-tab { + &__logo-wrapper { + height: 80px; + margin-bottom: 20px; + } + + &__logo { + max-height: 100%; + max-width: 100%; + } + + &__item { + padding: 10px 0; + } + + &__link-header { + padding-bottom: 15px; + + @media screen and (max-width: 575px) { + padding-bottom: 5px; + } + } + + &__link-item { + padding: 15px 0; + + @media screen and (max-width: 575px) { + padding: 5px 0; + } + } + + &__link-text { + color: $curious-blue; + } + + &__version-number { + padding-top: 5px; + font-size: 13px; + color: $dusty-gray; + } + + &__separator { + margin: 15px 0; + width: 80px; + border-color: $alto; + border: none; + height: 1px; + background-color: $alto; + color: $alto; + } + + &__about { + color: $dusty-gray; + margin-bottom: 15px; + } +} diff --git a/ui/app/components/pages/settings/info-tab/info-tab.component.js b/ui/app/components/pages/settings/info-tab/info-tab.component.js new file mode 100644 index 000000000..72f7d835e --- /dev/null +++ b/ui/app/components/pages/settings/info-tab/info-tab.component.js @@ -0,0 +1,136 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' + +export default class InfoTab extends PureComponent { + state = { + version: global.platform.getVersion(), + } + + static propTypes = { + tab: PropTypes.string, + metamask: PropTypes.object, + setCurrentCurrency: PropTypes.func, + setRpcTarget: PropTypes.func, + displayWarning: PropTypes.func, + revealSeedConfirmation: PropTypes.func, + warning: PropTypes.string, + location: PropTypes.object, + history: PropTypes.object, + } + + static contextTypes = { + t: PropTypes.func, + } + + renderInfoLinks () { + const { t } = this.context + + return ( + <div className="settings-page__content-item settings-page__content-item--without-height"> + <div className="info-tab__link-header"> + { t('links') } + </div> + <div className="info-tab__link-item"> + <a + href="https://metamask.io/privacy.html" + target="_blank" + rel="noopener noreferrer" + > + <span className="info-tab__link-text"> + { t('privacyMsg') } + </span> + </a> + </div> + <div className="info-tab__link-item"> + <a + href="https://metamask.io/terms.html" + target="_blank" + rel="noopener noreferrer" + > + <span className="info-tab__link-text"> + { t('terms') } + </span> + </a> + </div> + <div className="info-tab__link-item"> + <a + href="https://metamask.io/attributions.html" + target="_blank" + rel="noopener noreferrer" + > + <span className="info-tab__link-text"> + { t('attributions') } + </span> + </a> + </div> + <hr className="info-tab__separator" /> + <div className="info-tab__link-item"> + <a + href="https://support.metamask.io" + target="_blank" + rel="noopener noreferrer" + > + <span className="info-tab__link-text"> + { t('supportCenter') } + </span> + </a> + </div> + <div className="info-tab__link-item"> + <a + href="https://metamask.io/" + target="_blank" + rel="noopener noreferrer" + > + <span className="info-tab__link-text"> + { t('visitWebSite') } + </span> + </a> + </div> + <div className="info-tab__link-item"> + <a + href="mailto:help@metamask.io?subject=Feedback" + target="_blank" + rel="noopener noreferrer" + > + <span className="info-tab__link-text"> + { t('emailUs') } + </span> + </a> + </div> + </div> + ) + } + + render () { + const { t } = this.context + + return ( + <div className="settings-page__content"> + <div className="settings-page__content-row"> + <div className="settings-page__content-item settings-page__content-item--without-height"> + <div className="info-tab__logo-wrapper"> + <img + src="images/info-logo.png" + className="info-tab__logo" + /> + </div> + <div className="info-tab__item"> + <div className="info-tab__version-header"> + { t('metamaskVersion') } + </div> + <div className="info-tab__version-number"> + { this.state.version } + </div> + </div> + <div className="info-tab__item"> + <div className="info-tab__about"> + { t('builtInCalifornia') } + </div> + </div> + </div> + { this.renderInfoLinks() } + </div> + </div> + ) + } +} diff --git a/ui/app/components/pages/settings/info.js b/ui/app/components/pages/settings/info.js deleted file mode 100644 index bd9040499..000000000 --- a/ui/app/components/pages/settings/info.js +++ /dev/null @@ -1,120 +0,0 @@ -const { Component } = require('react') -const PropTypes = require('prop-types') -const h = require('react-hyperscript') - -class Info extends Component { - constructor (props) { - super(props) - - this.state = { - version: global.platform.getVersion(), - } - } - - renderLogo () { - return ( - h('div.settings__info-logo-wrapper', [ - h('img.settings__info-logo', { src: 'images/info-logo.png' }), - ]) - ) - } - - renderInfoLinks () { - return ( - h('div.settings__content-item.settings__content-item--without-height', [ - h('div.settings__info-link-header', this.context.t('links')), - h('div.settings__info-link-item', [ - h('a', { - href: 'https://metamask.io/privacy.html', - target: '_blank', - }, [ - h('span.settings__info-link', this.context.t('privacyMsg')), - ]), - ]), - h('div.settings__info-link-item', [ - h('a', { - href: 'https://metamask.io/terms.html', - target: '_blank', - }, [ - h('span.settings__info-link', this.context.t('terms')), - ]), - ]), - h('div.settings__info-link-item', [ - h('a', { - href: 'https://metamask.io/attributions.html', - target: '_blank', - }, [ - h('span.settings__info-link', this.context.t('attributions')), - ]), - ]), - h('hr.settings__info-separator'), - h('div.settings__info-link-item', [ - h('a', { - href: 'https://support.metamask.io', - target: '_blank', - }, [ - h('span.settings__info-link', this.context.t('supportCenter')), - ]), - ]), - h('div.settings__info-link-item', [ - h('a', { - href: 'https://metamask.io/', - target: '_blank', - }, [ - h('span.settings__info-link', this.context.t('visitWebSite')), - ]), - ]), - h('div.settings__info-link-item', [ - h('a', { - target: '_blank', - href: 'mailto:help@metamask.io?subject=Feedback', - }, [ - h('span.settings__info-link', this.context.t('emailUs')), - ]), - ]), - ]) - ) - } - - render () { - return ( - h('div.settings__content', [ - h('div.settings__content-row', [ - h('div.settings__content-item.settings__content-item--without-height', [ - this.renderLogo(), - h('div.settings__info-item', [ - h('div.settings__info-version-header', 'MetaMask Version'), - h('div.settings__info-version-number', this.state.version), - ]), - h('div.settings__info-item', [ - h( - 'div.settings__info-about', - this.context.t('builtInCalifornia') - ), - ]), - ]), - this.renderInfoLinks(), - ]), - ]) - ) - } -} - -Info.propTypes = { - tab: PropTypes.string, - metamask: PropTypes.object, - setCurrentCurrency: PropTypes.func, - setRpcTarget: PropTypes.func, - displayWarning: PropTypes.func, - revealSeedConfirmation: PropTypes.func, - warning: PropTypes.string, - location: PropTypes.object, - history: PropTypes.object, - t: PropTypes.func, -} - -Info.contextTypes = { - t: PropTypes.func, -} - -module.exports = Info diff --git a/ui/app/components/pages/settings/settings-tab/index.js b/ui/app/components/pages/settings/settings-tab/index.js new file mode 100644 index 000000000..9fdaafd3f --- /dev/null +++ b/ui/app/components/pages/settings/settings-tab/index.js @@ -0,0 +1 @@ +export { default } from './settings-tab.container' diff --git a/ui/app/components/pages/settings/settings-tab/index.scss b/ui/app/components/pages/settings/settings-tab/index.scss new file mode 100644 index 000000000..76a0cec6f --- /dev/null +++ b/ui/app/components/pages/settings/settings-tab/index.scss @@ -0,0 +1,51 @@ +.settings-tab { + &__error { + padding-bottom: 20px; + text-align: center; + color: $crimson; + } + + &__rpc-save-button { + align-self: flex-end; + padding: 5px; + text-transform: uppercase; + color: $dusty-gray; + cursor: pointer; + } + + &__rpc-save-button { + align-self: flex-end; + padding: 5px; + text-transform: uppercase; + color: $dusty-gray; + cursor: pointer; + } + + &__button--red { + border-color: lighten($monzo, 10%); + color: $monzo; + + &:active { + background: lighten($monzo, 55%); + border-color: $monzo; + } + + &:hover { + border-color: $monzo; + } + } + + &__button--orange { + border-color: lighten($ecstasy, 20%); + color: $ecstasy; + + &:active { + background: lighten($ecstasy, 40%); + border-color: $ecstasy; + } + + &:hover { + border-color: $ecstasy; + } + } +} diff --git a/ui/app/components/pages/settings/settings-tab/settings-tab.component.js b/ui/app/components/pages/settings/settings-tab/settings-tab.component.js new file mode 100644 index 000000000..9da624f56 --- /dev/null +++ b/ui/app/components/pages/settings/settings-tab/settings-tab.component.js @@ -0,0 +1,360 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import infuraCurrencies from '../../../../infura-conversion.json' +import validUrl from 'valid-url' +import { exportAsFile } from '../../../../util' +import SimpleDropdown from '../../../dropdowns/simple-dropdown' +import ToggleButton from 'react-toggle-button' +import { REVEAL_SEED_ROUTE } from '../../../../routes' +import locales from '../../../../../../app/_locales/index.json' +import TextField from '../../../text-field' +import Button from '../../../button' + +const sortedCurrencies = infuraCurrencies.objects.sort((a, b) => { + return a.quote.name.toLocaleLowerCase().localeCompare(b.quote.name.toLocaleLowerCase()) +}) + +const infuraCurrencyOptions = sortedCurrencies.map(({ quote: { code, name } }) => { + return { + displayValue: `${code.toUpperCase()} - ${name}`, + key: code, + value: code, + } +}) + +const localeOptions = locales.map(locale => { + return { + displayValue: `${locale.name}`, + key: locale.code, + value: locale.code, + } +}) + +export default class SettingsTab extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + metamask: PropTypes.object, + setUseBlockie: PropTypes.func, + setHexDataFeatureFlag: PropTypes.func, + setCurrentCurrency: PropTypes.func, + setRpcTarget: PropTypes.func, + delRpcTarget: PropTypes.func, + displayWarning: PropTypes.func, + revealSeedConfirmation: PropTypes.func, + setFeatureFlagToBeta: PropTypes.func, + showResetAccountConfirmationModal: PropTypes.func, + warning: PropTypes.string, + history: PropTypes.object, + isMascara: PropTypes.bool, + updateCurrentLocale: PropTypes.func, + currentLocale: PropTypes.string, + useBlockie: PropTypes.bool, + sendHexData: PropTypes.bool, + currentCurrency: PropTypes.string, + conversionDate: PropTypes.number, + } + + state = { + newRpc: '', + } + + renderCurrentConversion () { + const { t } = this.context + const { currentCurrency, conversionDate, setCurrentCurrency } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('currentConversion') }</span> + <span className="settings-page__content-description"> + { t('updatedWithDate', [Date(conversionDate)]) } + </span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <SimpleDropdown + placeholder={t('selectCurrency')} + options={infuraCurrencyOptions} + selectedOption={currentCurrency} + onSelect={newCurrency => setCurrentCurrency(newCurrency)} + /> + </div> + </div> + </div> + ) + } + + renderCurrentLocale () { + const { t } = this.context + const { updateCurrentLocale, currentLocale } = this.props + const currentLocaleMeta = locales.find(locale => locale.code === currentLocale) + const currentLocaleName = currentLocaleMeta ? currentLocaleMeta.name : '' + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span className="settings-page__content-label"> + { t('currentLanguage') } + </span> + <span className="settings-page__content-description"> + { currentLocaleName } + </span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <SimpleDropdown + placeholder={t('selectLocale')} + options={localeOptions} + selectedOption={currentLocale} + onSelect={async newLocale => updateCurrentLocale(newLocale)} + /> + </div> + </div> + </div> + ) + } + + renderNewRpcUrl () { + const { t } = this.context + const { newRpc } = this.state + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('newRPC') }</span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <TextField + type="text" + id="new-rpc" + placeholder={t('newRPC')} + value={newRpc} + onChange={e => this.setState({ newRpc: e.target.value })} + onKeyPress={e => { + if (e.key === 'Enter') { + this.validateRpc(newRpc) + } + }} + fullWidth + margin="none" + /> + <div + className="settings-tab__rpc-save-button" + onClick={e => { + e.preventDefault() + this.validateRpc(newRpc) + }} + > + { t('save') } + </div> + </div> + </div> + </div> + ) + } + + validateRpc (newRpc) { + const { setRpcTarget, displayWarning } = this.props + + if (validUrl.isWebUri(newRpc)) { + setRpcTarget(newRpc) + } else { + const appendedRpc = `http://${newRpc}` + + if (validUrl.isWebUri(appendedRpc)) { + displayWarning(this.context.t('uriErrorMsg')) + } else { + displayWarning(this.context.t('invalidRPC')) + } + } + } + + renderStateLogs () { + const { t } = this.context + const { displayWarning } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('stateLogs') }</span> + <span className="settings-page__content-description"> + { t('stateLogsDescription') } + </span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <Button + type="primary" + large + onClick={() => { + window.logStateString((err, result) => { + if (err) { + displayWarning(t('stateLogError')) + } else { + exportAsFile('MetaMask State Logs.json', result) + } + }) + }} + > + { t('downloadStateLogs') } + </Button> + </div> + </div> + </div> + ) + } + + renderSeedWords () { + const { t } = this.context + const { history } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('revealSeedWords') }</span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <Button + type="secondary" + large + onClick={event => { + event.preventDefault() + history.push(REVEAL_SEED_ROUTE) + }} + > + { t('revealSeedWords') } + </Button> + </div> + </div> + </div> + ) + } + + renderOldUI () { + const { t } = this.context + const { setFeatureFlagToBeta } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('useOldUI') }</span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <Button + type="secondary" + large + className="settings-tab__button--orange" + onClick={event => { + event.preventDefault() + setFeatureFlagToBeta() + }} + > + { t('useOldUI') } + </Button> + </div> + </div> + </div> + ) + } + + renderResetAccount () { + const { t } = this.context + const { showResetAccountConfirmationModal } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('resetAccount') }</span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <Button + type="secondary" + large + className="settings-tab__button--orange" + onClick={event => { + event.preventDefault() + showResetAccountConfirmationModal() + }} + > + { t('resetAccount') } + </Button> + </div> + </div> + </div> + ) + } + + renderBlockieOptIn () { + const { useBlockie, setUseBlockie } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ this.context.t('blockiesIdenticon') }</span> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <ToggleButton + value={useBlockie} + onToggle={value => setUseBlockie(!value)} + activeLabel="" + inactiveLabel="" + /> + </div> + </div> + </div> + ) + } + + renderHexDataOptIn () { + const { t } = this.context + const { sendHexData, setHexDataFeatureFlag } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('showHexData') }</span> + <div className="settings-page__content-description"> + { t('showHexDataDescription') } + </div> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <ToggleButton + value={sendHexData} + onToggle={value => setHexDataFeatureFlag(!value)} + activeLabel="" + inactiveLabel="" + /> + </div> + </div> + </div> + ) + } + + render () { + const { warning, isMascara } = this.props + + return ( + <div className="settings-page__content"> + { warning && <div className="settings-tab__error">{ warning }</div> } + { this.renderCurrentConversion() } + { this.renderCurrentLocale() } + { this.renderNewRpcUrl() } + { this.renderStateLogs() } + { this.renderSeedWords() } + { !isMascara && this.renderOldUI() } + { this.renderResetAccount() } + { this.renderBlockieOptIn() } + { this.renderHexDataOptIn() } + </div> + ) + } +} diff --git a/ui/app/components/pages/settings/settings-tab/settings-tab.container.js b/ui/app/components/pages/settings/settings-tab/settings-tab.container.js new file mode 100644 index 000000000..665b56f5c --- /dev/null +++ b/ui/app/components/pages/settings/settings-tab/settings-tab.container.js @@ -0,0 +1,59 @@ +import SettingsTab from './settings-tab.component' +import { compose } from 'recompose' +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { + setCurrentCurrency, + setRpcTarget, + displayWarning, + revealSeedConfirmation, + setUseBlockie, + updateCurrentLocale, + setFeatureFlag, + showModal, +} from '../../../../actions' + +const mapStateToProps = state => { + const { appState: { warning }, metamask } = state + const { + currentCurrency, + conversionDate, + useBlockie, + featureFlags: { sendHexData } = {}, + provider = {}, + isMascara, + currentLocale, + } = metamask + + return { + warning, + isMascara, + currentLocale, + currentCurrency, + conversionDate, + useBlockie, + sendHexData, + provider, + } +} + +const mapDispatchToProps = dispatch => { + return { + setCurrentCurrency: currency => dispatch(setCurrentCurrency(currency)), + setRpcTarget: newRpc => dispatch(setRpcTarget(newRpc)), + displayWarning: warning => dispatch(displayWarning(warning)), + revealSeedConfirmation: () => dispatch(revealSeedConfirmation()), + setUseBlockie: value => dispatch(setUseBlockie(value)), + updateCurrentLocale: key => dispatch(updateCurrentLocale(key)), + setFeatureFlagToBeta: () => { + return dispatch(setFeatureFlag('betaUI', false, 'OLD_UI_NOTIFICATION_MODAL')) + }, + setHexDataFeatureFlag: shouldShow => dispatch(setFeatureFlag('sendHexData', shouldShow)), + showResetAccountConfirmationModal: () => dispatch(showModal({ name: 'CONFIRM_RESET_ACCOUNT' })), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(SettingsTab) diff --git a/ui/app/components/pages/settings/settings.component.js b/ui/app/components/pages/settings/settings.component.js new file mode 100644 index 000000000..94a97bba1 --- /dev/null +++ b/ui/app/components/pages/settings/settings.component.js @@ -0,0 +1,54 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { Switch, Route, matchPath } from 'react-router-dom' +import TabBar from '../../tab-bar' +import SettingsTab from './settings-tab' +import InfoTab from './info-tab' +import { DEFAULT_ROUTE, SETTINGS_ROUTE, INFO_ROUTE } from '../../../routes' + +export default class SettingsPage extends PureComponent { + static propTypes = { + location: PropTypes.object, + history: PropTypes.object, + t: PropTypes.func, + } + + static contextTypes = { + t: PropTypes.func, + } + + render () { + const { history, location } = this.props + + return ( + <div className="main-container settings-page"> + <div className="settings-page__header"> + <div + className="settings-page__close-button" + onClick={() => history.push(DEFAULT_ROUTE)} + /> + <TabBar + tabs={[ + { content: this.context.t('settings'), key: SETTINGS_ROUTE }, + { content: this.context.t('info'), key: INFO_ROUTE }, + ]} + isActive={key => matchPath(location.pathname, { path: key, exact: true })} + onSelect={key => history.push(key)} + /> + </div> + <Switch> + <Route + exact + path={INFO_ROUTE} + component={InfoTab} + /> + <Route + exact + path={SETTINGS_ROUTE} + component={SettingsTab} + /> + </Switch> + </div> + ) + } +} diff --git a/ui/app/components/pages/settings/settings.js b/ui/app/components/pages/settings/settings.js deleted file mode 100644 index ff42a13de..000000000 --- a/ui/app/components/pages/settings/settings.js +++ /dev/null @@ -1,365 +0,0 @@ -const { Component } = require('react') -const { withRouter } = require('react-router-dom') -const { compose } = require('recompose') -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const connect = require('react-redux').connect -const actions = require('../../../actions') -const infuraCurrencies = require('../../../infura-conversion.json') -const validUrl = require('valid-url') -const { exportAsFile } = require('../../../util') -const SimpleDropdown = require('../../dropdowns/simple-dropdown') -const ToggleButton = require('react-toggle-button') -const { REVEAL_SEED_ROUTE } = require('../../../routes') -const locales = require('../../../../../app/_locales/index.json') - -const getInfuraCurrencyOptions = () => { - const sortedCurrencies = infuraCurrencies.objects.sort((a, b) => { - return a.quote.name.toLocaleLowerCase().localeCompare(b.quote.name.toLocaleLowerCase()) - }) - - return sortedCurrencies.map(({ quote: { code, name } }) => { - return { - displayValue: `${code.toUpperCase()} - ${name}`, - key: code, - value: code, - } - }) -} - -const getLocaleOptions = () => { - return locales.map((locale) => { - return { - displayValue: `${locale.name}`, - key: locale.code, - value: locale.code, - } - }) -} - -class Settings extends Component { - constructor (props) { - super(props) - - this.state = { - newRpc: '', - } - } - - renderBlockieOptIn () { - const { metamask: { useBlockie }, setUseBlockie } = this.props - - return h('div.settings__content-row', [ - h('div.settings__content-item', [ - h('span', this.context.t('blockiesIdenticon')), - ]), - h('div.settings__content-item', [ - h('div.settings__content-item-col', [ - h(ToggleButton, { - value: useBlockie, - onToggle: (value) => setUseBlockie(!value), - activeLabel: '', - inactiveLabel: '', - }), - ]), - ]), - ]) - } - - renderCurrentConversion () { - const { metamask: { currentCurrency, conversionDate }, setCurrentCurrency } = this.props - - return h('div.settings__content-row', [ - h('div.settings__content-item', [ - h('span', this.context.t('currentConversion')), - h('span.settings__content-description', `Updated ${Date(conversionDate)}`), - ]), - h('div.settings__content-item', [ - h('div.settings__content-item-col', [ - h(SimpleDropdown, { - placeholder: this.context.t('selectCurrency'), - options: getInfuraCurrencyOptions(), - selectedOption: currentCurrency, - onSelect: newCurrency => setCurrentCurrency(newCurrency), - }), - ]), - ]), - ]) - } - - renderCurrentLocale () { - const { updateCurrentLocale, currentLocale } = this.props - const currentLocaleMeta = locales.find(locale => locale.code === currentLocale) - const currentLocaleName = currentLocaleMeta ? currentLocaleMeta.name : '' - - return h('div.settings__content-row', [ - h('div.settings__content-item', [ - h('span', 'Current Language'), - h('span.settings__content-description', `${currentLocaleName}`), - ]), - h('div.settings__content-item', [ - h('div.settings__content-item-col', [ - h(SimpleDropdown, { - placeholder: 'Select Locale', - options: getLocaleOptions(), - selectedOption: currentLocale, - onSelect: async (newLocale) => { - updateCurrentLocale(newLocale) - }, - }), - ]), - ]), - ]) - } - - renderCurrentProvider () { - const { metamask: { provider = {} } } = this.props - let title, value, color - - switch (provider.type) { - - case 'mainnet': - title = this.context.t('currentNetwork') - value = this.context.t('mainnet') - color = '#038789' - break - - case 'ropsten': - title = this.context.t('currentNetwork') - value = this.context.t('ropsten') - color = '#e91550' - break - - case 'kovan': - title = this.context.t('currentNetwork') - value = this.context.t('kovan') - color = '#690496' - break - - case 'rinkeby': - title = this.context.t('currentNetwork') - value = this.context.t('rinkeby') - color = '#ebb33f' - break - - default: - title = this.context.t('currentRpc') - value = provider.rpcTarget - } - - return h('div.settings__content-row', [ - h('div.settings__content-item', title), - h('div.settings__content-item', [ - h('div.settings__content-item-col', [ - h('div.settings__provider-wrapper', [ - h('div.settings__provider-icon', { style: { background: color } }), - h('div', value), - ]), - ]), - ]), - ]) - } - - renderNewRpcUrl () { - return ( - h('div.settings__content-row', [ - h('div.settings__content-item', [ - h('span', this.context.t('newRPC')), - ]), - h('div.settings__content-item', [ - h('div.settings__content-item-col', [ - h('input.settings__input', { - placeholder: this.context.t('newRPC'), - onChange: event => this.setState({ newRpc: event.target.value }), - onKeyPress: event => { - if (event.key === 'Enter') { - this.validateRpc(this.state.newRpc) - } - }, - }), - h('div.settings__rpc-save-button', { - onClick: event => { - event.preventDefault() - this.validateRpc(this.state.newRpc) - }, - }, this.context.t('save')), - ]), - ]), - ]) - ) - } - - validateRpc (newRpc) { - const { setRpcTarget, displayWarning } = this.props - - if (validUrl.isWebUri(newRpc)) { - setRpcTarget(newRpc) - } else { - const appendedRpc = `http://${newRpc}` - - if (validUrl.isWebUri(appendedRpc)) { - displayWarning(this.context.t('uriErrorMsg')) - } else { - displayWarning(this.context.t('invalidRPC')) - } - } - } - - renderStateLogs () { - return ( - h('div.settings__content-row', [ - h('div.settings__content-item', [ - h('div', this.context.t('stateLogs')), - h( - 'div.settings__content-description', - this.context.t('stateLogsDescription') - ), - ]), - h('div.settings__content-item', [ - h('div.settings__content-item-col', [ - h('button.btn-primary.btn--large.settings__button', { - onClick (event) { - window.logStateString((err, result) => { - if (err) { - this.state.dispatch(actions.displayWarning(this.context.t('stateLogError'))) - } else { - exportAsFile('MetaMask State Logs.json', result) - } - }) - }, - }, this.context.t('downloadStateLogs')), - ]), - ]), - ]) - ) - } - - renderSeedWords () { - const { history } = this.props - - return ( - h('div.settings__content-row', [ - h('div.settings__content-item', this.context.t('revealSeedWords')), - h('div.settings__content-item', [ - h('div.settings__content-item-col', [ - h('button.btn-primary.btn--large.settings__button--red', { - onClick: event => { - event.preventDefault() - history.push(REVEAL_SEED_ROUTE) - }, - }, this.context.t('revealSeedWords')), - ]), - ]), - ]) - ) - } - - renderOldUI () { - const { setFeatureFlagToBeta } = this.props - - return ( - h('div.settings__content-row', [ - h('div.settings__content-item', this.context.t('useOldUI')), - h('div.settings__content-item', [ - h('div.settings__content-item-col', [ - h('button.btn-primary.btn--large.settings__button--orange', { - onClick (event) { - event.preventDefault() - setFeatureFlagToBeta() - }, - }, this.context.t('useOldUI')), - ]), - ]), - ]) - ) - } - - renderResetAccount () { - const { showResetAccountConfirmationModal } = this.props - - return h('div.settings__content-row', [ - h('div.settings__content-item', this.context.t('resetAccount')), - h('div.settings__content-item', [ - h('div.settings__content-item-col', [ - h('button.btn-primary.btn--large.settings__button--orange', { - onClick (event) { - event.preventDefault() - showResetAccountConfirmationModal() - }, - }, this.context.t('resetAccount')), - ]), - ]), - ]) - } - - render () { - const { warning, isMascara } = this.props - - return ( - h('div.settings__content', [ - warning && h('div.settings__error', warning), - this.renderCurrentConversion(), - this.renderCurrentLocale(), - // this.renderCurrentProvider(), - this.renderNewRpcUrl(), - this.renderStateLogs(), - this.renderSeedWords(), - !isMascara && this.renderOldUI(), - this.renderResetAccount(), - this.renderBlockieOptIn(), - ]) - ) - } -} - -Settings.propTypes = { - metamask: PropTypes.object, - setUseBlockie: PropTypes.func, - setCurrentCurrency: PropTypes.func, - setRpcTarget: PropTypes.func, - displayWarning: PropTypes.func, - revealSeedConfirmation: PropTypes.func, - setFeatureFlagToBeta: PropTypes.func, - showResetAccountConfirmationModal: PropTypes.func, - warning: PropTypes.string, - history: PropTypes.object, - isMascara: PropTypes.bool, - updateCurrentLocale: PropTypes.func, - currentLocale: PropTypes.string, - t: PropTypes.func, -} - -const mapStateToProps = state => { - return { - metamask: state.metamask, - warning: state.appState.warning, - isMascara: state.metamask.isMascara, - currentLocale: state.metamask.currentLocale, - } -} - -const mapDispatchToProps = dispatch => { - return { - setCurrentCurrency: currency => dispatch(actions.setCurrentCurrency(currency)), - setRpcTarget: newRpc => dispatch(actions.setRpcTarget(newRpc)), - displayWarning: warning => dispatch(actions.displayWarning(warning)), - revealSeedConfirmation: () => dispatch(actions.revealSeedConfirmation()), - setUseBlockie: value => dispatch(actions.setUseBlockie(value)), - updateCurrentLocale: key => dispatch(actions.updateCurrentLocale(key)), - setFeatureFlagToBeta: () => { - return dispatch(actions.setFeatureFlag('betaUI', false, 'OLD_UI_NOTIFICATION_MODAL')) - }, - showResetAccountConfirmationModal: () => { - return dispatch(actions.showModal({ name: 'CONFIRM_RESET_ACCOUNT' })) - }, - } -} - -Settings.contextTypes = { - t: PropTypes.func, -} - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(Settings) diff --git a/ui/app/components/pages/unlock-page/index.scss b/ui/app/components/pages/unlock-page/index.scss index 3d44bd037..6bd52282d 100644 --- a/ui/app/components/pages/unlock-page/index.scss +++ b/ui/app/components/pages/unlock-page/index.scss @@ -14,6 +14,7 @@ align-self: stretch; justify-content: center; flex: 1 0 auto; + height: 100vh; } &__mascot-container { diff --git a/ui/app/components/pending-msg-details.js b/ui/app/components/pending-msg-details.js deleted file mode 100644 index f16fcb1c7..000000000 --- a/ui/app/components/pending-msg-details.js +++ /dev/null @@ -1,56 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const inherits = require('util').inherits -const connect = require('react-redux').connect - -const AccountPanel = require('./account-panel') - -PendingMsgDetails.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect()(PendingMsgDetails) - - -inherits(PendingMsgDetails, Component) -function PendingMsgDetails () { - Component.call(this) -} - -PendingMsgDetails.prototype.render = function () { - var state = this.props - var msgData = state.txData - - var msgParams = msgData.msgParams || {} - var address = msgParams.from || state.selectedAddress - var identity = state.identities[address] || { address: address } - var account = state.accounts[address] || { address: address } - - return ( - h('div', { - key: msgData.id, - style: { - margin: '10px 20px', - }, - }, [ - - // account that will sign - h(AccountPanel, { - showFullAddress: true, - identity: identity, - account: account, - imageifyIdenticons: state.imageifyIdenticons, - }), - - // message data - h('.tx-data.flex-column.flex-justify-center.flex-grow.select-none', [ - h('.flex-column.flex-space-between', [ - h('label.font-small.allcaps', this.context.t('message')), - h('span.font-small', msgParams.data), - ]), - ]), - - ]) - ) -} diff --git a/ui/app/components/pending-msg.js b/ui/app/components/pending-msg.js deleted file mode 100644 index 21a7864e4..000000000 --- a/ui/app/components/pending-msg.js +++ /dev/null @@ -1,73 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const inherits = require('util').inherits -const PendingTxDetails = require('./pending-msg-details') -const connect = require('react-redux').connect - -PendingMsg.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect()(PendingMsg) - - -inherits(PendingMsg, Component) -function PendingMsg () { - Component.call(this) -} - -PendingMsg.prototype.render = function () { - var state = this.props - var msgData = state.txData - - return ( - - h('div', { - key: msgData.id, - style: { - maxWidth: '350px', - }, - }, [ - - // header - h('h3', { - style: { - fontWeight: 'bold', - textAlign: 'center', - }, - }, this.context.t('signMessage')), - - h('.error', { - style: { - margin: '10px', - }, - }, [ - this.context.t('signNotice'), - h('a', { - href: 'https://medium.com/metamask/the-new-secure-way-to-sign-data-in-your-browser-6af9dd2a1527', - style: { color: 'rgb(247, 134, 28)' }, - onClick: (event) => { - event.preventDefault() - const url = 'https://medium.com/metamask/the-new-secure-way-to-sign-data-in-your-browser-6af9dd2a1527' - global.platform.openWindow({ url }) - }, - }, this.context.t('readMore')), - ]), - - // message details - h(PendingTxDetails, state), - - // sign + cancel - h('.flex-row.flex-space-around', [ - h('button', { - onClick: state.cancelMessage, - }, this.context.t('cancel')), - h('button', { - onClick: state.signMessage, - }, this.context.t('sign')), - ]), - ]) - - ) -} diff --git a/ui/app/components/qr-code.js b/ui/app/components/qr-code.js index 3b2c62f49..d3242ddf5 100644 --- a/ui/app/components/qr-code.js +++ b/ui/app/components/qr-code.js @@ -26,7 +26,7 @@ function QrCodeView () { QrCodeView.prototype.render = function () { const props = this.props const { message, data } = props.Qr - const address = `${isHexPrefixed(data) ? 'ethereum:' : ''}${data}` + const address = `${isHexPrefixed(data) ? 'ethereum:' : ''}${checksumAddress(data)}` const qrImage = qrCode(4, 'M') qrImage.addData(address) qrImage.make() diff --git a/ui/app/components/selected-account/selected-account.component.js b/ui/app/components/selected-account/selected-account.component.js index 6c202141e..4f98df9b6 100644 --- a/ui/app/components/selected-account/selected-account.component.js +++ b/ui/app/components/selected-account/selected-account.component.js @@ -1,9 +1,9 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import copyToClipboard from 'copy-to-clipboard' -import { addressSlicer } from '../../util' +import { addressSlicer, checksumAddress } from '../../util' -const Tooltip = require('../tooltip-v2.js') +const Tooltip = require('../tooltip-v2.js').default class SelectedAccount extends Component { state = { @@ -22,6 +22,7 @@ class SelectedAccount extends Component { render () { const { t } = this.context const { selectedAddress, selectedIdentity } = this.props + const checksummedAddress = checksumAddress(selectedAddress) return ( <div className="selected-account"> @@ -34,14 +35,14 @@ class SelectedAccount extends Component { onClick={() => { this.setState({ copied: true }) setTimeout(() => this.setState({ copied: false }), 3000) - copyToClipboard(selectedAddress) + copyToClipboard(checksummedAddress) }} > <div className="selected-account__name"> { selectedIdentity.name } </div> <div className="selected-account__address"> - { addressSlicer(selectedAddress) } + { addressSlicer(checksummedAddress) } </div> </div> </Tooltip> diff --git a/ui/app/components/selected-account/tests/selected-account-component.test.js b/ui/app/components/selected-account/tests/selected-account-component.test.js new file mode 100644 index 000000000..78a94d1c8 --- /dev/null +++ b/ui/app/components/selected-account/tests/selected-account-component.test.js @@ -0,0 +1,16 @@ +import React from 'react' +import assert from 'assert' +import { render } from 'enzyme' +import SelectedAccount from '../selected-account.component' + +describe('SelectedAccount Component', () => { + it('should render checksummed address', () => { + const wrapper = render(<SelectedAccount + selectedAddress="0x1b82543566f41a7db9a9a75fc933c340ffb55c9d" + selectedIdentity={{ name: 'testName' }} + />, { context: { t: () => {}}}) + // Checksummed version of address is displayed + assert.equal(wrapper.find('.selected-account__address').text(), '0x1B82...5C9D') + assert.equal(wrapper.find('.selected-account__name').text(), 'testName') + }) +}) diff --git a/ui/app/components/send/currency-display/tests/currency-display.test.js b/ui/app/components/send/currency-display/tests/currency-display.test.js new file mode 100644 index 000000000..c9560b81c --- /dev/null +++ b/ui/app/components/send/currency-display/tests/currency-display.test.js @@ -0,0 +1,91 @@ +import React from 'react' +import assert from 'assert' +import sinon from 'sinon' +import { shallow, mount } from 'enzyme' +import CurrencyDisplay from '../currency-display' + +describe('', () => { + + const token = { + address: '0xTest', + symbol: 'TST', + decimals: '13', + } + + it('retuns ETH value for wei value', () => { + const wrapper = mount(<CurrencyDisplay />, {context: {t: str => str + '_t'}}) + + const value = wrapper.instance().getValueToRender({ + // 1000000000000000000 + value: 'DE0B6B3A7640000', + }) + + assert.equal(value, 1) + }) + + it('returns value of token based on token decimals', () => { + const wrapper = mount(<CurrencyDisplay />, {context: {t: str => str + '_t'}}) + + const value = wrapper.instance().getValueToRender({ + selectedToken: token, + // 1000000000000000000 + value: 'DE0B6B3A7640000', + }) + + assert.equal(value, 100000) + }) + + it('returns hex value with decimal adjustment', () => { + + const wrapper = mount( + <CurrencyDisplay + selectedToken={token} + />, {context: {t: str => str + '_t'}}) + + const value = wrapper.instance().getAmount(1) + // 10000000000000 + assert.equal(value, '9184e72a000') + }) + + it('#getConvertedValueToRender converts input value based on conversionRate', () => { + + const wrapper = mount( + <CurrencyDisplay + primaryCurrency={'usd'} + convertedCurrency={'ja'} + conversionRate={2} + />, {context: {t: str => str + '_t'}}) + + const value = wrapper.instance().getConvertedValueToRender(32) + + assert.equal(value, 64) + }) + + it('#onlyRenderConversions renders single element for converted currency and value', () => { + const wrapper = mount( + <CurrencyDisplay + convertedCurrency={'test'} + />, {context: {t: str => str + '_t'}}) + + const value = wrapper.instance().onlyRenderConversions(10) + assert.equal(value.props.className, 'currency-display__converted-value') + assert.equal(value.props.children, '10 TEST') + }) + + it('simulates change value in input', () => { + const handleChangeSpy = sinon.spy() + + const wrapper = shallow( + <CurrencyDisplay + onChange={handleChangeSpy} + />, {context: {t: str => str + '_t'}}) + + const input = wrapper.find('input') + input.simulate('focus') + input.simulate('change', { target: { value: '100' } }) + + assert.equal(wrapper.state().valueToRender, '100') + assert.equal(wrapper.find('input').prop('value'), '100') + }) + +}) diff --git a/ui/app/components/send/send-content/send-content.component.js b/ui/app/components/send/send-content/send-content.component.js index df7bcb7cc..1b03ffd2b 100644 --- a/ui/app/components/send/send-content/send-content.component.js +++ b/ui/app/components/send/send-content/send-content.component.js @@ -12,20 +12,27 @@ export default class SendContent extends Component { static propTypes = { updateGas: PropTypes.func, scanQrCode: PropTypes.func, + showHexData: PropTypes.bool, }; + updateGas = (updateData) => this.props.updateGas(updateData) + render () { return ( <PageContainerContent> <div className="send-v2__form"> <SendFromRow /> <SendToRow - updateGas={(updateData) => this.props.updateGas(updateData)} + updateGas={this.updateGas} scanQrCode={ _ => this.props.scanQrCode()} /> - <SendAmountRow updateGas={(updateData) => this.props.updateGas(updateData)} /> + <SendAmountRow updateGas={this.updateGas} /> <SendGasRow /> - <SendHexDataRow /> + {(this.props.showHexData && ( + <SendHexDataRow + updateGas={this.updateGas} + /> + ))} </div> </PageContainerContent> ) diff --git a/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.component.js b/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.component.js index 063930db3..62a74a77b 100644 --- a/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.component.js +++ b/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.component.js @@ -7,6 +7,7 @@ export default class SendHexDataRow extends Component { data: PropTypes.string, inError: PropTypes.bool, updateSendHexData: PropTypes.func.isRequired, + updateGas: PropTypes.func.isRequired, }; static contextTypes = { @@ -14,9 +15,10 @@ export default class SendHexDataRow extends Component { }; onInput = (event) => { - const {updateSendHexData} = this.props - event.target.value = event.target.value.replace(/\n/g, '') - updateSendHexData(event.target.value || null) + const {updateSendHexData, updateGas} = this.props + const data = event.target.value.replace(/\n/g, '') || null + updateSendHexData(data) + updateGas({ data }) } render () { diff --git a/ui/app/components/send/send-content/send-to-row/send-to-row.component.js b/ui/app/components/send/send-content/send-to-row/send-to-row.component.js index 1163dcffc..17c75c817 100644 --- a/ui/app/components/send/send-content/send-to-row/send-to-row.component.js +++ b/ui/app/components/send/send-content/send-to-row/send-to-row.component.js @@ -8,6 +8,7 @@ export default class SendToRow extends Component { static propTypes = { closeToDropdown: PropTypes.func, + hasHexData: PropTypes.bool.isRequired, inError: PropTypes.bool, network: PropTypes.string, openToDropdown: PropTypes.func, @@ -25,8 +26,8 @@ export default class SendToRow extends Component { }; handleToChange (to, nickname = '', toError) { - const { updateSendTo, updateSendToError, updateGas } = this.props - const toErrorObject = getToErrorObject(to, toError) + const { hasHexData, updateSendTo, updateSendToError, updateGas } = this.props + const toErrorObject = getToErrorObject(to, toError, hasHexData) updateSendTo(to, nickname) updateSendToError(toErrorObject) if (toErrorObject.to === null) { @@ -48,7 +49,7 @@ export default class SendToRow extends Component { return ( <SendRowWrapper errorType={'to'} - label={`${this.context.t('to')}`} + label={`${this.context.t('to')}: `} showError={inError} > <EnsInput diff --git a/ui/app/components/send/send-content/send-to-row/send-to-row.container.js b/ui/app/components/send/send-content/send-to-row/send-to-row.container.js index 1c9c9d518..3ee188bad 100644 --- a/ui/app/components/send/send-content/send-to-row/send-to-row.container.js +++ b/ui/app/components/send/send-content/send-to-row/send-to-row.container.js @@ -3,6 +3,7 @@ import { getCurrentNetwork, getSendTo, getSendToAccounts, + getSendHexData, } from '../../send.selectors.js' import { getToDropdownOpen, @@ -22,6 +23,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(SendToRow) function mapStateToProps (state) { return { + hasHexData: Boolean(getSendHexData(state)), inError: sendToIsInError(state), network: getCurrentNetwork(state), to: getSendTo(state), diff --git a/ui/app/components/send/send-content/send-to-row/send-to-row.utils.js b/ui/app/components/send/send-content/send-to-row/send-to-row.utils.js index 6b90a9f09..0eeaa3a11 100644 --- a/ui/app/components/send/send-content/send-to-row/send-to-row.utils.js +++ b/ui/app/components/send/send-content/send-to-row/send-to-row.utils.js @@ -4,9 +4,11 @@ const { } = require('../../send.constants') const { isValidAddress } = require('../../../../util') -function getToErrorObject (to, toError = null) { +function getToErrorObject (to, toError = null, hasHexData = false) { if (!to) { - toError = REQUIRED_ERROR + if (!hasHexData) { + toError = REQUIRED_ERROR + } } else if (!isValidAddress(to) && !toError) { toError = INVALID_RECIPIENT_ADDRESS_ERROR } diff --git a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-component.test.js b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-component.test.js index 781371004..591229deb 100644 --- a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-component.test.js +++ b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-component.test.js @@ -102,7 +102,7 @@ describe('SendToRow Component', function () { assert.equal(errorType, 'to') - assert.equal(label, 'to_t') + assert.equal(label, 'to_t: ') assert.equal(showError, false) }) diff --git a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-container.test.js b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-container.test.js index 92355c00a..dfce7652f 100644 --- a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-container.test.js +++ b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-container.test.js @@ -24,6 +24,7 @@ proxyquire('../send-to-row.container.js', { }, '../../send.selectors.js': { getCurrentNetwork: (s) => `mockNetwork:${s}`, + getSendHexData: (s) => s, getSendTo: (s) => `mockTo:${s}`, getSendToAccounts: (s) => `mockToAccounts:${s}`, }, @@ -41,6 +42,7 @@ describe('send-to-row container', () => { it('should map the correct properties to props', () => { assert.deepEqual(mapStateToProps('mockState'), { + hasHexData: true, inError: 'mockInError:mockState', network: 'mockNetwork:mockState', to: 'mockTo:mockState', diff --git a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-utils.test.js b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-utils.test.js index 4d2447c32..c779aeb76 100644 --- a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-utils.test.js +++ b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-utils.test.js @@ -29,6 +29,12 @@ describe('send-to-row utils', () => { }) }) + it('should return null if to is falsy and hexData is truthy', () => { + assert.deepEqual(getToErrorObject(null, undefined, true), { + to: null, + }) + }) + it('should return an invalid recipient error if to is truthy but invalid', () => { assert.deepEqual(getToErrorObject('mockInvalidTo'), { to: INVALID_RECIPIENT_ADDRESS_ERROR, diff --git a/ui/app/components/send/send-content/tests/send-content-component.test.js b/ui/app/components/send/send-content/tests/send-content-component.test.js index d5bb6693c..c5a11c8bb 100644 --- a/ui/app/components/send/send-content/tests/send-content-component.test.js +++ b/ui/app/components/send/send-content/tests/send-content-component.test.js @@ -8,12 +8,13 @@ import SendAmountRow from '../send-amount-row/send-amount-row.container' import SendFromRow from '../send-from-row/send-from-row.container' import SendGasRow from '../send-gas-row/send-gas-row.container' import SendToRow from '../send-to-row/send-to-row.container' +import SendHexDataRow from '../send-hex-data-row/send-hex-data-row.container' describe('SendContent Component', function () { let wrapper beforeEach(() => { - wrapper = shallow(<SendContent />) + wrapper = shallow(<SendContent showHexData={true} />) }) describe('render', () => { @@ -33,6 +34,17 @@ describe('SendContent Component', function () { assert(PageContainerContentChild.childAt(1).is(SendToRow)) assert(PageContainerContentChild.childAt(2).is(SendAmountRow)) assert(PageContainerContentChild.childAt(3).is(SendGasRow)) + assert(PageContainerContentChild.childAt(4).is(SendHexDataRow)) + }) + + it('should not render the SendHexDataRow if props.showHexData is false', () => { + wrapper.setProps({ showHexData: false }) + const PageContainerContentChild = wrapper.find(PageContainerContent).children() + assert(PageContainerContentChild.childAt(0).is(SendFromRow)) + assert(PageContainerContentChild.childAt(1).is(SendToRow)) + assert(PageContainerContentChild.childAt(2).is(SendAmountRow)) + assert(PageContainerContentChild.childAt(3).is(SendGasRow)) + assert.equal(PageContainerContentChild.childAt(4).exists(), false) }) }) }) 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 518cff06e..230bf450f 100644 --- a/ui/app/components/send/send-footer/send-footer.component.js +++ b/ui/app/components/send/send-footer/send-footer.component.js @@ -86,9 +86,9 @@ export default class SendFooter extends Component { } formShouldBeDisabled () { - const { inError, selectedToken, tokenBalance, gasTotal, to } = this.props + const { data, inError, selectedToken, tokenBalance, gasTotal, to } = this.props const missingTokenBalance = selectedToken && !tokenBalance - return inError || !gasTotal || missingTokenBalance || !to + return inError || !gasTotal || missingTokenBalance || !(data || to) } render () { diff --git a/ui/app/components/send/send.component.js b/ui/app/components/send/send.component.js index 0d8ffd179..fb7beca16 100644 --- a/ui/app/components/send/send.component.js +++ b/ui/app/components/send/send.component.js @@ -62,7 +62,7 @@ export default class SendTransactionScreen extends PersistentForm { } } - updateGas ({ to: updatedToAddress, amount: value } = {}) { + updateGas ({ to: updatedToAddress, amount: value, data } = {}) { const { amount, blockGasLimit, @@ -86,6 +86,7 @@ export default class SendTransactionScreen extends PersistentForm { selectedToken, to: getToAddressForGasUpdate(updatedToAddress, currentToAddress), value: value || amount, + data, }) } @@ -193,7 +194,7 @@ export default class SendTransactionScreen extends PersistentForm { } render () { - const { history } = this.props + const { history, showHexData } = this.props return ( <div className="page-container"> @@ -201,6 +202,7 @@ export default class SendTransactionScreen extends PersistentForm { <SendContent updateGas={(updateData) => this.updateGas(updateData)} scanQrCode={_ => this.props.scanQrCode()} + showHexData={showHexData} /> <SendFooter history={history}/> </div> diff --git a/ui/app/components/send/send.container.js b/ui/app/components/send/send.container.js index 41735de64..87056499f 100644 --- a/ui/app/components/send/send.container.js +++ b/ui/app/components/send/send.container.js @@ -18,6 +18,7 @@ import { getSelectedTokenToFiatRate, getSendAmount, getSendEditingTransactionId, + getSendHexDataFeatureFlagState, getSendFromObject, getSendTo, getTokenBalance, @@ -64,6 +65,7 @@ function mapStateToProps (state) { recentBlocks: getRecentBlocks(state), selectedAddress: getSelectedAddress(state), selectedToken: getSelectedToken(state), + showHexData: getSendHexDataFeatureFlagState(state), to: getSendTo(state), tokenBalance: getTokenBalance(state), tokenContract: getSelectedTokenContract(state), @@ -84,9 +86,10 @@ function mapDispatchToProps (dispatch) { selectedToken, to, value, + data, }) => { !editingTransactionId - ? dispatch(updateGasData({ recentBlocks, selectedAddress, selectedToken, blockGasLimit, to, value })) + ? dispatch(updateGasData({ recentBlocks, selectedAddress, selectedToken, blockGasLimit, to, value, data })) : dispatch(setGasTotal(calcGasTotal(gasLimit, gasPrice))) }, updateSendTokenBalance: ({ selectedToken, tokenContract, address }) => { diff --git a/ui/app/components/send/send.selectors.js b/ui/app/components/send/send.selectors.js index ab3f6d34b..22e379693 100644 --- a/ui/app/components/send/send.selectors.js +++ b/ui/app/components/send/send.selectors.js @@ -34,6 +34,7 @@ const selectors = { getSelectedTokenToFiatRate, getSendAmount, getSendHexData, + getSendHexDataFeatureFlagState, getSendEditingTransactionId, getSendErrors, getSendFrom, @@ -216,6 +217,10 @@ function getSendHexData (state) { return state.metamask.send.data } +function getSendHexDataFeatureFlagState (state) { + return state.metamask.featureFlags.sendHexData +} + function getSendEditingTransactionId (state) { return state.metamask.send.editingTransactionId } diff --git a/ui/app/components/send/send.utils.js b/ui/app/components/send/send.utils.js index aa255c3d4..a18a9e4b3 100644 --- a/ui/app/components/send/send.utils.js +++ b/ui/app/components/send/send.utils.js @@ -200,16 +200,20 @@ function doesAmountErrorRequireUpdate ({ return amountErrorRequiresUpdate } -async function estimateGas ({ selectedAddress, selectedToken, blockGasLimit, to, value, gasPrice, estimateGasMethod }) { +async function estimateGas ({ + selectedAddress, + selectedToken, + blockGasLimit, + to, + value, + data, + gasPrice, + estimateGasMethod, +}) { const paramsForGasEstimate = { from: selectedAddress, value, gasPrice } - if (selectedToken) { - paramsForGasEstimate.value = '0x0' - paramsForGasEstimate.data = generateTokenTransferData({ toAddress: to, amount: value, selectedToken }) - } - // if recipient has no code, gas is 21k max: - if (!selectedToken) { + if (!selectedToken && !data) { const code = Boolean(to) && await global.eth.getCode(to) if (!code || code === '0x') { return SIMPLE_GAS_COST @@ -218,7 +222,19 @@ async function estimateGas ({ selectedAddress, selectedToken, blockGasLimit, to, return BASE_TOKEN_GAS_COST } - paramsForGasEstimate.to = selectedToken ? selectedToken.address : to + if (selectedToken) { + paramsForGasEstimate.value = '0x0' + paramsForGasEstimate.data = generateTokenTransferData({ toAddress: to, amount: value, selectedToken }) + paramsForGasEstimate.to = selectedToken.address + } else { + if (data) { + paramsForGasEstimate.data = data + } + + if (to) { + paramsForGasEstimate.to = to + } + } // if not, fall back to block gasLimit paramsForGasEstimate.gas = ethUtil.addHexPrefix(multiplyCurrencies(blockGasLimit, 0.95, { diff --git a/ui/app/components/send/tests/send-component.test.js b/ui/app/components/send/tests/send-component.test.js index 6194ec508..f4943e707 100644 --- a/ui/app/components/send/tests/send-component.test.js +++ b/ui/app/components/send/tests/send-component.test.js @@ -47,6 +47,7 @@ describe('Send Component', function () { recentBlocks={['mockBlock']} selectedAddress={'mockSelectedAddress'} selectedToken={'mockSelectedToken'} + showHexData={true} tokenBalance={'mockTokenBalance'} tokenContract={'mockTokenContract'} updateAndSetGasTotal={propsMethodSpies.updateAndSetGasTotal} @@ -288,6 +289,7 @@ describe('Send Component', function () { selectedToken: 'mockSelectedToken', to: '', value: 'mockAmount', + data: undefined, } ) }) @@ -328,5 +330,9 @@ describe('Send Component', function () { } ) }) + + it('should pass showHexData to SendContent', () => { + assert.equal(wrapper.find(SendContent).props().showHexData, true) + }) }) }) diff --git a/ui/app/components/send/tests/send-container.test.js b/ui/app/components/send/tests/send-container.test.js index 57e332780..6aa4bf826 100644 --- a/ui/app/components/send/tests/send-container.test.js +++ b/ui/app/components/send/tests/send-container.test.js @@ -39,6 +39,7 @@ proxyquire('../send.container.js', { getSelectedToken: (s) => `mockSelectedToken:${s}`, getSelectedTokenContract: (s) => `mockTokenContract:${s}`, getSelectedTokenToFiatRate: (s) => `mockTokenToFiatRate:${s}`, + getSendHexDataFeatureFlagState: (s) => `mockSendHexDataFeatureFlagState:${s}`, getSendAmount: (s) => `mockAmount:${s}`, getSendTo: (s) => `mockTo:${s}`, getSendEditingTransactionId: (s) => `mockEditingTransactionId:${s}`, @@ -73,6 +74,7 @@ describe('send container', () => { recentBlocks: 'mockRecentBlocks:mockState', selectedAddress: 'mockSelectedAddress:mockState', selectedToken: 'mockSelectedToken:mockState', + showHexData: 'mockSendHexDataFeatureFlagState:mockState', to: 'mockTo:mockState', tokenBalance: 'mockTokenBalance:mockState', tokenContract: 'mockTokenContract:mockState', @@ -103,6 +105,7 @@ describe('send container', () => { selectedToken: { address: '0x1' }, to: 'mockTo', value: 'mockValue', + data: undefined, } it('should dispatch a setGasTotal action when editingTransactionId is truthy', () => { @@ -115,14 +118,14 @@ describe('send container', () => { }) it('should dispatch an updateGasData action when editingTransactionId is falsy', () => { - const { selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value } = mockProps + const { selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value, data } = mockProps mapDispatchToPropsObject.updateAndSetGasTotal( Object.assign({}, mockProps, {editingTransactionId: false}) ) assert(dispatchSpy.calledOnce) assert.deepEqual( actionSpies.updateGasData.getCall(0).args[0], - { selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value } + { selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value, data } ) }) }) diff --git a/ui/app/components/send/tests/send-selectors-test-data.js b/ui/app/components/send/tests/send-selectors-test-data.js index 8f9c19314..8b939dadb 100644 --- a/ui/app/components/send/tests/send-selectors-test-data.js +++ b/ui/app/components/send/tests/send-selectors-test-data.js @@ -2,7 +2,7 @@ module.exports = { 'metamask': { 'isInitialized': true, 'isUnlocked': true, - 'featureFlags': {'betaUI': true}, + 'featureFlags': {'betaUI': true, 'sendHexData': true}, 'rpcTarget': 'https://rawtestrpc.metamask.io/', 'identities': { '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825': { diff --git a/ui/app/components/send/tests/send-selectors.test.js b/ui/app/components/send/tests/send-selectors.test.js index 218da656b..1a47cd209 100644 --- a/ui/app/components/send/tests/send-selectors.test.js +++ b/ui/app/components/send/tests/send-selectors.test.js @@ -31,6 +31,7 @@ const { getSendFrom, getSendFromBalance, getSendFromObject, + getSendHexDataFeatureFlagState, getSendMaxModeState, getSendTo, getSendToAccounts, @@ -379,6 +380,15 @@ describe('send selectors', () => { }) }) + describe('getSendHexDataFeatureFlagState()', () => { + it('should return the sendHexData feature flag state', () => { + assert.deepEqual( + getSendHexDataFeatureFlagState(mockState), + true + ) + }) + }) + describe('getSendFrom()', () => { it('should return the send.from', () => { assert.deepEqual( diff --git a/ui/app/components/send/tests/send-utils.test.js b/ui/app/components/send/tests/send-utils.test.js index 18dde495a..b72d87eee 100644 --- a/ui/app/components/send/tests/send-utils.test.js +++ b/ui/app/components/send/tests/send-utils.test.js @@ -304,10 +304,13 @@ describe('send utils', () => { selectedAddress: 'mockAddress', to: '0xisContract', estimateGasMethod: sinon.stub().callsFake( - (data, cb) => cb( - data.to.match(/willFailBecauseOf:/) ? { message: data.to.match(/:(.+)$/)[1] } : null, - { toString: (n) => `0xabc${n}` } - ) + ({to}, cb) => { + const err = typeof to === 'string' && to.match(/willFailBecauseOf:/) + ? new Error(to.match(/:(.+)$/)[1]) + : null + const result = { toString: (n) => `0xabc${n}` } + return cb(err, result) + } ), } const baseExpectedCall = { @@ -364,6 +367,18 @@ describe('send utils', () => { assert.equal(result, '0xabc16') }) + it('should call ethQuery.estimateGas without a recipient if the recipient is empty and data passed', async () => { + const data = 'mockData' + const to = '' + const result = await estimateGas({...baseMockParams, data, to}) + assert.equal(baseMockParams.estimateGasMethod.callCount, 1) + assert.deepEqual( + baseMockParams.estimateGasMethod.getCall(0).args[0], + { gasPrice: undefined, value: undefined, data, from: baseExpectedCall.from, gas: baseExpectedCall.gas}, + ) + assert.equal(result, '0xabc16') + }) + it(`should return ${SIMPLE_GAS_COST} if ethQuery.getCode does not return '0x'`, async () => { assert.equal(baseMockParams.estimateGasMethod.callCount, 0) const result = await estimateGas(Object.assign({}, baseMockParams, { to: '0x123' })) @@ -407,7 +422,7 @@ describe('send utils', () => { to: 'isContract willFailBecauseOf:some other error', })) } catch (err) { - assert.deepEqual(err, { message: 'some other error' }) + assert.equal(err.message, 'some other error') } }) }) diff --git a/ui/app/components/send/to-autocomplete/to-autocomplete.js b/ui/app/components/send/to-autocomplete/to-autocomplete.js index 49ebf49d9..39d15dfa7 100644 --- a/ui/app/components/send/to-autocomplete/to-autocomplete.js +++ b/ui/app/components/send/to-autocomplete/to-autocomplete.js @@ -5,6 +5,7 @@ const inherits = require('util').inherits const AccountListItem = require('../account-list-item/account-list-item.component').default const connect = require('react-redux').connect const Tooltip = require('../../tooltip') +const checksumAddress = require('../../../util').checksumAddress ToAutoComplete.contextTypes = { t: PropTypes.func, @@ -48,7 +49,7 @@ ToAutoComplete.prototype.renderDropdown = function () { account, className: 'account-list-item__dropdown', handleClick: () => { - onChange(account.address) + onChange(checksumAddress(account.address)) closeDropdown() }, icon: this.getListItemIcon(account.address, to), diff --git a/ui/app/components/sender-to-recipient/index.scss b/ui/app/components/sender-to-recipient/index.scss index a97393b8f..0ab0413be 100644 --- a/ui/app/components/sender-to-recipient/index.scss +++ b/ui/app/components/sender-to-recipient/index.scss @@ -1,5 +1,5 @@ .sender-to-recipient { - &__container { + &--default { width: 100%; display: flex; flex-direction: row; @@ -8,67 +8,113 @@ position: relative; flex: 0 0 auto; height: 42px; - } - &__tooltip-wrapper { - min-width: 0; - } + .sender-to-recipient { + &__tooltip-wrapper { + min-width: 0; + } - &__tooltip-container { - max-width: 100%; - } + &__tooltip-container { + max-width: 100%; + } - &__sender, - &__recipient { - display: flex; - flex-direction: row; - align-items: center; - flex: 1; - padding: 0 16px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + &__party { + display: flex; + flex-direction: row; + align-items: center; + flex: 1; + padding: 0 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; - &__sender { - padding-right: 30px; - cursor: pointer; - } + &--sender { + padding-right: 30px; + cursor: pointer; + } + + &--recipient { + padding-left: 30px; + border-left: 1px solid $geyser; + + &-with-address { + cursor: pointer; + } + } + } - &__recipient { - padding-left: 30px; - border-left: 1px solid $geyser; + &__arrow-container { + position: absolute; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } - &--with-address { - cursor: pointer; + &__arrow-circle { + background: $white; + padding: 5px; + border: 1px solid $geyser; + border-radius: 20px; + height: 32px; + width: 32px; + display: flex; + justify-content: center; + align-items: center; + } + + &__name { + padding-left: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: .875rem; + } } } - &__arrow-container { - position: absolute; - height: 100%; + &--cards { + width: 100%; display: flex; - align-items: center; + flex-direction: row; justify-content: center; - } + position: relative; + flex: 0 0 auto; - &__arrow-circle { - background: $white; - padding: 5px; - border: 1px solid $geyser; - border-radius: 20px; - height: 32px; - width: 32px; - display: flex; - justify-content: center; - align-items: center; - } + .sender-to-recipient { + &__party { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + flex: 1; + border-radius: 4px; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.08); + padding: 6px; + background: $white; + cursor: pointer; + min-width: 0; + color: $dusty-gray; + } + + &__tooltip-wrapper { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } - &__name { - padding-left: 14px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: .875rem; + &__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: .5rem; + } + + &__arrow-container { + display: flex; + justify-content: center; + align-items: center; + } + } } } 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 index cae173b56..e71bd7406 100644 --- a/ui/app/components/sender-to-recipient/sender-to-recipient.component.js +++ b/ui/app/components/sender-to-recipient/sender-to-recipient.component.js @@ -1,16 +1,31 @@ -import React, { Component } from 'react' +import React, { PureComponent } from 'react' import PropTypes from 'prop-types' +import classnames from 'classnames' import Identicon from '../identicon' import Tooltip from '../tooltip-v2' import copyToClipboard from 'copy-to-clipboard' +import { DEFAULT_VARIANT, CARDS_VARIANT } from './sender-to-recipient.constants' +import { checksumAddress } from '../../util' -export default class SenderToRecipient extends Component { +const variantHash = { + [DEFAULT_VARIANT]: 'sender-to-recipient--default', + [CARDS_VARIANT]: 'sender-to-recipient--cards', +} + +export default class SenderToRecipient extends PureComponent { static propTypes = { senderName: PropTypes.string, senderAddress: PropTypes.string, recipientName: PropTypes.string, recipientAddress: PropTypes.string, t: PropTypes.func, + variant: PropTypes.oneOf([DEFAULT_VARIANT, CARDS_VARIANT]), + addressOnly: PropTypes.bool, + assetImage: PropTypes.string, + } + + static defaultProps = { + variant: DEFAULT_VARIANT, } static contextTypes = { @@ -22,24 +37,66 @@ export default class SenderToRecipient extends Component { recipientAddressCopied: false, } + renderSenderIdenticon () { + return !this.props.addressOnly && ( + <div className="sender-to-recipient__sender-icon"> + <Identicon + address={checksumAddress(this.props.senderAddress)} + diameter={24} + /> + </div> + ) + } + + renderSenderAddress () { + const { t } = this.context + const { senderName, senderAddress, addressOnly } = this.props + const checksummedSenderAddress = checksumAddress(senderAddress) + + return ( + <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"> + { addressOnly ? `${t('from')}: ${checksummedSenderAddress}` : senderName } + </div> + </Tooltip> + ) + } + + renderRecipientIdenticon () { + const { recipientAddress, assetImage } = this.props + const checksummedRecipientAddress = checksumAddress(recipientAddress) + + return !this.props.addressOnly && ( + <div className="sender-to-recipient__sender-icon"> + <Identicon + address={checksummedRecipientAddress} + diameter={24} + image={assetImage} + /> + </div> + ) + } + renderRecipientWithAddress () { const { t } = this.context - const { recipientName, recipientAddress } = this.props + const { recipientName, recipientAddress, addressOnly } = this.props + const checksummedRecipientAddress = checksumAddress(recipientAddress) return ( <div - className="sender-to-recipient__recipient sender-to-recipient__recipient--with-address" + className="sender-to-recipient__party sender-to-recipient__party--recipient sender-to-recipient__party--recipient-with-address" onClick={() => { this.setState({ recipientAddressCopied: true }) - copyToClipboard(recipientAddress) + copyToClipboard(checksummedRecipientAddress) }} > - <div className="sender-to-recipient__sender-icon"> - <Identicon - address={recipientAddress} - diameter={24} - /> - </div> + { this.renderRecipientIdenticon() } <Tooltip position="bottom" title={this.state.recipientAddressCopied ? t('copiedExclamation') : t('copyAddress')} @@ -47,8 +104,12 @@ export default class SenderToRecipient extends Component { 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 className="sender-to-recipient__name"> + { + addressOnly + ? `${t('to')}: ${checksummedRecipientAddress}` + : (recipientName || this.context.t('newContract')) + } </div> </Tooltip> </div> @@ -57,46 +118,25 @@ export default class SenderToRecipient extends Component { 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"> + <div className="sender-to-recipient__party sender-to-recipient__party--recipient"> + { !this.props.addressOnly && <i className="fa fa-file-text-o" /> } + <div className="sender-to-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> + renderArrow () { + return this.props.variant === CARDS_VARIANT + ? ( + <div className="sender-to-recipient__arrow-container"> + <img + height={20} + src="./images/caret-right.svg" + /> </div> + ) : ( <div className="sender-to-recipient__arrow-container"> <div className="sender-to-recipient__arrow-circle"> <img @@ -106,6 +146,26 @@ export default class SenderToRecipient extends Component { /> </div> </div> + ) + } + + render () { + const { senderAddress, recipientAddress, variant } = this.props + const checksummedSenderAddress = checksumAddress(senderAddress) + + return ( + <div className={classnames(variantHash[variant])}> + <div + className={classnames('sender-to-recipient__party sender-to-recipient__party--sender')} + onClick={() => { + this.setState({ senderAddressCopied: true }) + copyToClipboard(checksummedSenderAddress) + }} + > + { this.renderSenderIdenticon() } + { this.renderSenderAddress() } + </div> + { this.renderArrow() } { recipientAddress ? this.renderRecipientWithAddress() diff --git a/ui/app/components/sender-to-recipient/sender-to-recipient.constants.js b/ui/app/components/sender-to-recipient/sender-to-recipient.constants.js new file mode 100644 index 000000000..166228932 --- /dev/null +++ b/ui/app/components/sender-to-recipient/sender-to-recipient.constants.js @@ -0,0 +1,3 @@ +// Component design variants +export const DEFAULT_VARIANT = 'DEFAULT_VARIANT' +export const CARDS_VARIANT = 'CARDS_VARIANT' diff --git a/ui/app/components/shapeshift-form.js b/ui/app/components/shapeshift-form.js index 2c4ba40bf..a842bcc8b 100644 --- a/ui/app/components/shapeshift-form.js +++ b/ui/app/components/shapeshift-form.js @@ -9,6 +9,8 @@ const { shapeShiftSubview, pairUpdate, buyWithShapeShift } = require('../actions const { isValidAddress } = require('../util') const SimpleDropdown = require('./dropdowns/simple-dropdown') +import Button from './button' + function mapStateToProps (state) { const { coinOptions, @@ -242,8 +244,10 @@ ShapeshiftForm.prototype.render = function () { ]), - !depositAddress && h('button.btn-primary.btn--large.shapeshift-form__shapeshift-buy-btn', { - className: btnClass, + !depositAddress && h(Button, { + type: 'primary', + large: true, + className: `${btnClass} shapeshift-form__shapeshift-buy-btn`, disabled: !token, onClick: () => this.onBuyWithShapeShift(), }, [this.context.t('buy')]), diff --git a/ui/app/components/shift-list-item.js b/ui/app/components/shift-list-item.js index 4334aacba..b87bf959e 100644 --- a/ui/app/components/shift-list-item.js +++ b/ui/app/components/shift-list-item.js @@ -35,12 +35,13 @@ function ShiftListItem () { } ShiftListItem.prototype.render = function () { - return h('div.tx-list-item.tx-list-clickable', { + return h('div.transaction-list-item.tx-list-clickable', { style: { paddingTop: '20px', paddingBottom: '20px', justifyContent: 'space-around', alignItems: 'center', + flexDirection: 'row', }, }, [ h('div', { diff --git a/ui/app/components/sidebars/index.js b/ui/app/components/sidebars/index.js new file mode 100644 index 000000000..732925f69 --- /dev/null +++ b/ui/app/components/sidebars/index.js @@ -0,0 +1 @@ +export { default } from './sidebar.component' diff --git a/ui/app/components/sidebars/index.scss b/ui/app/components/sidebars/index.scss new file mode 100644 index 000000000..5ab0664df --- /dev/null +++ b/ui/app/components/sidebars/index.scss @@ -0,0 +1,74 @@ +.sidebar-right-enter { + transition: transform 300ms ease-in-out; + transform: translateX(-100%); +} + +.sidebar-right-enter.sidebar-right-enter-active { + transition: transform 300ms ease-in-out; + transform: translateX(0%); +} + +.sidebar-right-leave { + transition: transform 200ms ease-out; + transform: translateX(0%); +} + +.sidebar-right-leave.sidebar-right-leave-active { + transition: transform 200ms ease-out; + transform: translateX(-100%); +} + +.sidebar-left-enter { + transition: transform 300ms ease-in-out; + transform: translateX(100%); +} + +.sidebar-left-enter.sidebar-left-enter-active { + transition: transform 300ms ease-in-out; + transform: translateX(0%); +} + +.sidebar-left-leave { + transition: transform 200ms ease-out; + transform: translateX(0%); +} + +.sidebar-left-leave.sidebar-left-leave-active { + transition: transform 200ms ease-out; + transform: translateX(100%); +} + +.sidebar-left { + flex: 1 0 230px; + background: rgb(250, 250, 250); + z-index: $sidebar-z-index; + position: fixed; + left: 15%; + right: 0; + bottom: 0; + opacity: 1; + visibility: visible; + will-change: transform; + overflow-y: auto; + box-shadow: rgba(0, 0, 0, .15) 2px 2px 4px; + width: 85%; + height: 100%; + + @media screen and (min-width: 769px) { + width: 408px; + left: calc(100% - 408px); + } +} + +.sidebar-overlay { + z-index: $sidebar-overlay-z-index; + position: fixed; + height: 100%; + width: 100%; + left: 0; + right: 0; + bottom: 0; + opacity: 1; + visibility: visible; + background-color: rgba(0, 0, 0, .3); +}
\ No newline at end of file diff --git a/ui/app/components/sidebars/sidebar.component.js b/ui/app/components/sidebars/sidebar.component.js new file mode 100644 index 000000000..57cdd7111 --- /dev/null +++ b/ui/app/components/sidebars/sidebar.component.js @@ -0,0 +1,49 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ReactCSSTransitionGroup from 'react-addons-css-transition-group' +import WalletView from '../wallet-view' +import { WALLET_VIEW_SIDEBAR } from './sidebar.constants' + +export default class Sidebar extends Component { + + static propTypes = { + sidebarOpen: PropTypes.bool, + hideSidebar: PropTypes.func, + transitionName: PropTypes.string, + type: PropTypes.string, + }; + + renderOverlay () { + return <div className="sidebar-overlay" onClick={() => this.props.hideSidebar()} /> + } + + renderSidebarContent () { + const { type } = this.props + + switch (type) { + case WALLET_VIEW_SIDEBAR: + return <WalletView responsiveDisplayClassname={'sidebar-right' } /> + default: + return null + } + + } + + render () { + const { transitionName, sidebarOpen } = this.props + + return ( + <div> + <ReactCSSTransitionGroup + transitionName={transitionName} + transitionEnterTimeout={300} + transitionLeaveTimeout={200} + > + { sidebarOpen ? this.renderSidebarContent() : null } + </ReactCSSTransitionGroup> + { sidebarOpen ? this.renderOverlay() : null } + </div> + ) + } + +} diff --git a/ui/app/components/sidebars/sidebar.constants.js b/ui/app/components/sidebars/sidebar.constants.js new file mode 100644 index 000000000..1613a8245 --- /dev/null +++ b/ui/app/components/sidebars/sidebar.constants.js @@ -0,0 +1 @@ +export const WALLET_VIEW_SIDEBAR = 'wallet-view' diff --git a/ui/app/components/sidebars/tests/sidebars-component.test.js b/ui/app/components/sidebars/tests/sidebars-component.test.js new file mode 100644 index 000000000..e2d77518a --- /dev/null +++ b/ui/app/components/sidebars/tests/sidebars-component.test.js @@ -0,0 +1,88 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import sinon from 'sinon' +import ReactCSSTransitionGroup from 'react-addons-css-transition-group' +import Sidebar from '../sidebar.component.js' + +import WalletView from '../../wallet-view' + +const propsMethodSpies = { + hideSidebar: sinon.spy(), +} + +describe('Sidebar Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<Sidebar + sidebarOpen={false} + hideSidebar={propsMethodSpies.hideSidebar} + transitionName={'someTransition'} + type={'wallet-view'} + />) + }) + + afterEach(() => { + propsMethodSpies.hideSidebar.resetHistory() + }) + + describe('renderOverlay', () => { + let renderOverlay + + beforeEach(() => { + renderOverlay = shallow(wrapper.instance().renderOverlay()) + }) + + it('should render a overlay element', () => { + assert(renderOverlay.hasClass('sidebar-overlay')) + }) + + it('should pass the correct onClick function to the element', () => { + assert.equal(propsMethodSpies.hideSidebar.callCount, 0) + renderOverlay.props().onClick() + assert.equal(propsMethodSpies.hideSidebar.callCount, 1) + }) + }) + + describe('renderSidebarContent', () => { + let renderSidebarContent + + beforeEach(() => { + wrapper.setProps({ type: 'wallet-view' }) + renderSidebarContent = wrapper.instance().renderSidebarContent() + }) + + it('should render sidebar content with the correct props', () => { + wrapper.setProps({ type: 'wallet-view' }) + renderSidebarContent = wrapper.instance().renderSidebarContent() + assert.equal(renderSidebarContent.props.responsiveDisplayClassname, 'sidebar-right') + }) + + it('should not render with an unrecognized type', () => { + wrapper.setProps({ type: 'foobar' }) + renderSidebarContent = wrapper.instance().renderSidebarContent() + assert.equal(renderSidebarContent, undefined) + }) + }) + + describe('render', () => { + it('should render a div with one child', () => { + assert(wrapper.is('div')) + assert.equal(wrapper.children().length, 1) + }) + + it('should render the ReactCSSTransitionGroup without any children', () => { + assert(wrapper.children().at(0).is(ReactCSSTransitionGroup)) + assert.equal(wrapper.children().at(0).children().length, 0) + }) + + it('should render sidebar content and the overlay if sidebarOpen is true', () => { + wrapper.setProps({ sidebarOpen: true }) + assert.equal(wrapper.children().length, 2) + assert(wrapper.children().at(1).hasClass('sidebar-overlay')) + assert.equal(wrapper.children().at(0).children().length, 1) + assert(wrapper.children().at(0).children().at(0).is(WalletView)) + }) + }) +}) diff --git a/ui/app/components/signature-request.js b/ui/app/components/signature-request.js index 2e0102d1a..d76eb5ef8 100644 --- a/ui/app/components/signature-request.js +++ b/ui/app/components/signature-request.js @@ -8,6 +8,7 @@ const ethUtil = require('ethereumjs-util') const classnames = require('classnames') const { compose } = require('recompose') const { withRouter } = require('react-router-dom') +const { ObjectInspector } = require('react-inspector') const AccountDropdownMini = require('./dropdowns/account-dropdown-mini') @@ -23,6 +24,7 @@ const { } = require('../selectors.js') import { clearConfirmTransaction } from '../ducks/confirm-transaction.duck' +import Button from './button' const { DEFAULT_ROUTE } = require('../routes') @@ -168,12 +170,29 @@ SignatureRequest.prototype.msgHexToText = function (hex) { } } +// eslint-disable-next-line react/display-name +SignatureRequest.prototype.renderTypedDataV3 = function (data) { + const { domain, message } = JSON.parse(data) + return [ + h('div.request-signature__typed-container', [ + domain ? h('div', [ + h('h1', 'Domain'), + h(ObjectInspector, { data: domain, expandLevel: 1, name: 'domain' }), + ]) : '', + message ? h('div', [ + h('h1', 'Message'), + h(ObjectInspector, { data: message, expandLevel: 1, name: 'message' }), + ]) : '', + ]), + ] +} + SignatureRequest.prototype.renderBody = function () { let rows let notice = this.context.t('youSign') + ':' const { txData } = this.props - const { type, msgParams: { data } } = txData + const { type, msgParams: { data, version } } = txData if (type === 'personal_sign') { rows = [{ name: this.context.t('message'), value: this.msgHexToText(data) }] @@ -185,7 +204,7 @@ SignatureRequest.prototype.renderBody = function () { h('span.request-signature__help-link', { onClick: () => { global.platform.openWindow({ - url: 'https://consensys.zendesk.com/hc/en-us/articles/360004427792', + url: 'https://metamask.zendesk.com/hc/en-us/articles/360015488751', }) }, }, this.context.t('learnMore'))] @@ -204,9 +223,9 @@ SignatureRequest.prototype.renderBody = function () { }), }, [notice]), - h('div.request-signature__rows', [ - - ...rows.map(({ name, value }) => { + h('div.request-signature__rows', type === 'eth_signTypedData' && version === 'V3' ? + this.renderTypedDataV3(data) : + rows.map(({ name, value }) => { if (typeof value === 'boolean') { value = value.toString() } @@ -215,9 +234,7 @@ SignatureRequest.prototype.renderBody = function () { h('div.request-signature__row-value', value), ]) }), - - ]), - + ), ]) } @@ -248,7 +265,10 @@ SignatureRequest.prototype.renderFooter = function () { } return h('div.request-signature__footer', [ - h('button.btn-default.btn--large.request-signature__footer__cancel-button', { + h(Button, { + type: 'default', + large: true, + className: 'request-signature__footer__cancel-button', onClick: event => { cancel(event).then(() => { this.props.clearConfirmTransaction() @@ -256,7 +276,9 @@ SignatureRequest.prototype.renderFooter = function () { }) }, }, this.context.t('cancel')), - h('button.btn-primary.btn--large', { + h(Button, { + type: 'primary', + large: true, onClick: event => { sign(event).then(() => { this.props.clearConfirmTransaction() diff --git a/ui/app/components/tabs/tab/tab.component.js b/ui/app/components/tabs/tab/tab.component.js index a59da8904..9e590391c 100644 --- a/ui/app/components/tabs/tab/tab.component.js +++ b/ui/app/components/tabs/tab/tab.component.js @@ -3,13 +3,13 @@ import PropTypes from 'prop-types' import classnames from 'classnames' const Tab = props => { - const { name, onClick, isActive, tabIndex } = props + const { name, onClick, isActive, tabIndex, className, activeClassName } = props return ( <li className={classnames( - 'tab', - isActive && 'tab--active', + className, + { [activeClassName]: isActive }, )} onClick={event => { event.preventDefault() @@ -26,6 +26,13 @@ Tab.propTypes = { onClick: PropTypes.func, isActive: PropTypes.bool, tabIndex: PropTypes.number, + className: PropTypes.string, + activeClassName: PropTypes.string, +} + +Tab.defaultProps = { + className: 'tab', + activeClassName: 'tab--active', } export default Tab diff --git a/ui/app/components/token-balance.js b/ui/app/components/token-balance.js deleted file mode 100644 index 99ca7335c..000000000 --- a/ui/app/components/token-balance.js +++ /dev/null @@ -1,120 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const inherits = require('util').inherits -const TokenTracker = require('eth-token-tracker') -const connect = require('react-redux').connect -const selectors = require('../selectors') -const log = require('loglevel') - -function mapStateToProps (state) { - return { - userAddress: selectors.getSelectedAddress(state), - } -} - -module.exports = connect(mapStateToProps)(TokenBalance) - - -inherits(TokenBalance, Component) -function TokenBalance () { - this.state = { - string: '', - symbol: '', - isLoading: true, - error: null, - } - Component.call(this) -} - -TokenBalance.prototype.render = function () { - const state = this.state - const { symbol, string, isLoading } = state - const { balanceOnly } = this.props - - return isLoading - ? h('span', '') - : h('span.token-balance', [ - h('span.hide-text-overflow.token-balance__amount', string), - !balanceOnly && h('span.token-balance__symbol', symbol), - ]) -} - -TokenBalance.prototype.componentDidMount = function () { - this.createFreshTokenTracker() -} - -TokenBalance.prototype.createFreshTokenTracker = function () { - if (this.tracker) { - // Clean up old trackers when refreshing: - this.tracker.stop() - this.tracker.removeListener('update', this.balanceUpdater) - this.tracker.removeListener('error', this.showError) - } - - if (!global.ethereumProvider) return - const { userAddress, token } = this.props - - this.tracker = new TokenTracker({ - userAddress, - provider: global.ethereumProvider, - tokens: [token], - pollingInterval: 8000, - }) - - - // Set up listener instances for cleaning up - this.balanceUpdater = this.updateBalance.bind(this) - this.showError = error => { - this.setState({ error, isLoading: false }) - } - this.tracker.on('update', this.balanceUpdater) - this.tracker.on('error', this.showError) - - this.tracker.updateBalances() - .then(() => { - this.updateBalance(this.tracker.serialize()) - }) - .catch((reason) => { - log.error(`Problem updating balances`, reason) - this.setState({ isLoading: false }) - }) -} - -TokenBalance.prototype.componentDidUpdate = function (nextProps) { - const { - userAddress: oldAddress, - token: { address: oldTokenAddress }, - } = this.props - const { - userAddress: newAddress, - token: { address: newTokenAddress }, - } = nextProps - - if ((!oldAddress || !newAddress) && (!oldTokenAddress || !newTokenAddress)) return - if ((oldAddress === newAddress) && (oldTokenAddress === newTokenAddress)) return - - this.setState({ isLoading: true }) - this.createFreshTokenTracker() -} - -TokenBalance.prototype.updateBalance = function (tokens = []) { - if (!this.tracker.running) { - return - } - - const [{ string, symbol }] = tokens - - this.setState({ - string, - symbol, - isLoading: false, - }) -} - -TokenBalance.prototype.componentWillUnmount = function () { - if (!this.tracker) return - this.tracker.stop() - this.tracker.removeListener('update', this.balanceUpdater) - this.tracker.removeListener('error', this.showError) -} - diff --git a/ui/app/components/token-balance/index.js b/ui/app/components/token-balance/index.js new file mode 100644 index 000000000..f7da15cf8 --- /dev/null +++ b/ui/app/components/token-balance/index.js @@ -0,0 +1 @@ +export { default } from './token-balance.container' diff --git a/ui/app/components/token-balance/token-balance.component.js b/ui/app/components/token-balance/token-balance.component.js new file mode 100644 index 000000000..2b4f73980 --- /dev/null +++ b/ui/app/components/token-balance/token-balance.component.js @@ -0,0 +1,23 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +export default class TokenBalance extends PureComponent { + static propTypes = { + string: PropTypes.string, + symbol: PropTypes.string, + error: PropTypes.string, + className: PropTypes.string, + withSymbol: PropTypes.bool, + } + + render () { + const { className, string, withSymbol, symbol } = this.props + + return ( + <div className={classnames('hide-text-overflow', className)}> + { string + (withSymbol ? ` ${symbol}` : '') } + </div> + ) + } +} diff --git a/ui/app/components/pages/confirm-add-token/token-balance/token-balance.container.js b/ui/app/components/token-balance/token-balance.container.js index bc1289ce1..adc001f83 100644 --- a/ui/app/components/pages/confirm-add-token/token-balance/token-balance.container.js +++ b/ui/app/components/token-balance/token-balance.container.js @@ -1,8 +1,8 @@ import { connect } from 'react-redux' import { compose } from 'recompose' -import withTokenTracker from '../../../../helpers/with-token-tracker' +import withTokenTracker from '../../higher-order-components/with-token-tracker' import TokenBalance from './token-balance.component' -import selectors from '../../../../selectors' +import selectors from '../../selectors' const mapStateToProps = state => { return { diff --git a/ui/app/components/token-cell.js b/ui/app/components/token-cell.js index 4100d76a5..477d97597 100644 --- a/ui/app/components/token-cell.js +++ b/ui/app/components/token-cell.js @@ -18,7 +18,7 @@ function mapStateToProps (state) { userAddress: selectors.getSelectedAddress(state), contractExchangeRates: state.metamask.contractExchangeRates, conversionRate: state.metamask.conversionRate, - sidebarOpen: state.appState.sidebarOpen, + sidebarOpen: state.appState.sidebar.isOpen, } } @@ -56,8 +56,8 @@ TokenCell.prototype.render = function () { sidebarOpen, currentCurrency, // userAddress, + image, } = props - let currentTokenToFiatRate let currentTokenInFiat let formattedFiat = '' @@ -97,6 +97,7 @@ TokenCell.prototype.render = function () { diameter: 50, address, network, + image, }), h('div.token-list-item__balance-ellipsis', null, [ diff --git a/ui/app/components/token-currency-display/index.js b/ui/app/components/token-currency-display/index.js new file mode 100644 index 000000000..6065cae1f --- /dev/null +++ b/ui/app/components/token-currency-display/index.js @@ -0,0 +1 @@ +export { default } from './token-currency-display.component' diff --git a/ui/app/components/token-currency-display/token-currency-display.component.js b/ui/app/components/token-currency-display/token-currency-display.component.js new file mode 100644 index 000000000..4bb09a4b6 --- /dev/null +++ b/ui/app/components/token-currency-display/token-currency-display.component.js @@ -0,0 +1,54 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import CurrencyDisplay from '../currency-display/currency-display.component' +import { getTokenData } from '../../helpers/transactions.util' +import { getTokenValue, calcTokenAmount } from '../../token-util' + +export default class TokenCurrencyDisplay extends PureComponent { + static propTypes = { + transactionData: PropTypes.string, + token: PropTypes.object, + } + + state = { + displayValue: '', + } + + componentDidMount () { + this.setDisplayValue() + } + + componentDidUpdate (prevProps) { + const { transactionData } = this.props + const { transactionData: prevTransactionData } = prevProps + + if (transactionData !== prevTransactionData) { + this.setDisplayValue() + } + } + + setDisplayValue () { + const { transactionData: data, token } = this.props + const { decimals = '', symbol = '' } = token + const tokenData = getTokenData(data) + + let displayValue + + if (tokenData.params && tokenData.params.length) { + const tokenValue = getTokenValue(tokenData.params) + const tokenAmount = calcTokenAmount(tokenValue, decimals) + displayValue = `${tokenAmount} ${symbol}` + } + + this.setState({ displayValue }) + } + + render () { + return ( + <CurrencyDisplay + {...this.props} + displayValue={this.state.displayValue} + /> + ) + } +} diff --git a/ui/app/components/token-list.js b/ui/app/components/token-list.js index 42351cf89..6a88f30bf 100644 --- a/ui/app/components/token-list.js +++ b/ui/app/components/token-list.js @@ -13,6 +13,7 @@ function mapStateToProps (state) { network: state.metamask.network, tokens: state.metamask.tokens, userAddress: selectors.getSelectedAddress(state), + assetImages: state.metamask.assetImages, } } @@ -44,10 +45,9 @@ function TokenList () { } TokenList.prototype.render = function () { - const { userAddress } = this.props + const { userAddress, assetImages } = this.props const state = this.state const { tokens, isLoading, error } = state - if (isLoading) { return this.message(this.context.t('loadingTokens')) } @@ -74,7 +74,10 @@ TokenList.prototype.render = function () { ]) } - return h('div', tokens.map((tokenData) => h(TokenCell, tokenData))) + return h('div', tokens.map((tokenData) => { + tokenData.image = assetImages[tokenData.address] + return h(TokenCell, tokenData) + })) } diff --git a/ui/app/components/tooltip-v2.js b/ui/app/components/tooltip-v2.js index 05a5efc80..054782203 100644 --- a/ui/app/components/tooltip-v2.js +++ b/ui/app/components/tooltip-v2.js @@ -1,33 +1,66 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const inherits = require('util').inherits -const ReactTippy = require('react-tippy').Tooltip +import PropTypes from 'prop-types' +import React, {PureComponent} from 'react' +import {Tooltip as ReactTippy} from 'react-tippy' -module.exports = Tooltip +export default class Tooltip extends PureComponent { + static defaultProps = { + arrow: true, + children: null, + containerClassName: '', + hideOnClick: false, + onHidden: null, + position: 'left', + size: 'small', + title: null, + trigger: 'mouseenter', + wrapperClassName: '', + } -inherits(Tooltip, Component) -function Tooltip () { - Component.call(this) -} - -Tooltip.prototype.render = function () { - const props = this.props - const { position, title, children, wrapperClassName, containerClassName, onHidden } = props + static propTypes = { + arrow: PropTypes.bool, + children: PropTypes.node, + containerClassName: PropTypes.string, + onHidden: PropTypes.func, + position: PropTypes.oneOf([ + 'top', + 'right', + 'bottom', + 'left', + ]), + size: PropTypes.oneOf([ + 'small', 'regular', 'big', + ]), + title: PropTypes.string, + trigger: PropTypes.any, + wrapperClassName: PropTypes.string, + } - return h('div', { - className: wrapperClassName, - }, [ + render () { + const {arrow, children, containerClassName, position, size, title, trigger, onHidden, wrapperClassName } = this.props - h(ReactTippy, { - title, - position: position || 'left', - trigger: 'mouseenter', - hideOnClick: false, - size: 'small', - arrow: true, - className: containerClassName, - onHidden, - }, children), + if (!title) { + return ( + <div className={wrapperClassName}> + {children} + </div> + ) + } - ]) + return ( + <div className={wrapperClassName}> + <ReactTippy + className={containerClassName} + title={title} + position={position} + trigger={trigger} + hideOnClick={false} + size={size} + arrow={arrow} + onHidden={onHidden} + > + {children} + </ReactTippy> + </div> + ) + } } diff --git a/ui/app/components/transaction-action/index.js b/ui/app/components/transaction-action/index.js new file mode 100644 index 000000000..a6e9097f1 --- /dev/null +++ b/ui/app/components/transaction-action/index.js @@ -0,0 +1 @@ +export { default } from './transaction-action.component' diff --git a/ui/app/components/transaction-action/tests/transaction-action.component.test.js b/ui/app/components/transaction-action/tests/transaction-action.component.test.js new file mode 100644 index 000000000..b22a9db39 --- /dev/null +++ b/ui/app/components/transaction-action/tests/transaction-action.component.test.js @@ -0,0 +1,162 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import sinon from 'sinon' +import TransactionAction from '../transaction-action.component' + +describe('TransactionAction Component', () => { + const t = key => key + + + describe('Outgoing transaction', () => { + beforeEach(() => { + global.eth = { + getCode: sinon.stub().callsFake(address => { + const code = address === 'approveAddress' ? 'contract' : '0x' + return Promise.resolve(code) + }), + } + }) + + it('should render -- when methodData is still fetching', () => { + const methodData = { data: {}, done: false, error: null } + const transaction = { + id: 1, + status: 'confirmed', + submittedTime: 1534045442919, + time: 1534045440641, + txParams: { + from: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0x96', + to: '0x50a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706', + value: '0x2386f26fc10000', + }, + } + + const wrapper = shallow(<TransactionAction + methodData={methodData} + transaction={transaction} + className="transaction-action" + />, { context: { t }}) + + assert.equal(wrapper.find('.transaction-action').length, 1) + assert.equal(wrapper.text(), '--') + }) + + it('should render Sent Ether', () => { + const methodData = { data: {}, done: true, error: null } + const transaction = { + id: 1, + status: 'confirmed', + submittedTime: 1534045442919, + time: 1534045440641, + txParams: { + from: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0x96', + to: 'sentEtherAddress', + value: '0x2386f26fc10000', + }, + } + + const wrapper = shallow(<TransactionAction + methodData={methodData} + transaction={transaction} + className="transaction-action" + />, { context: { t }}) + + assert.equal(wrapper.find('.transaction-action').length, 1) + wrapper.setState({ transactionAction: 'sentEther' }) + assert.equal(wrapper.text(), 'sentEther') + }) + + it('should render Approved', async () => { + const methodData = { + data: { + name: 'Approve', + params: [ + { type: 'address' }, + { type: 'uint256' }, + ], + }, + done: true, + error: null, + } + const transaction = { + id: 1, + status: 'confirmed', + submittedTime: 1534045442919, + time: 1534045440641, + txParams: { + from: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0x96', + to: 'approveAddress', + value: '0x2386f26fc10000', + data: '0x095ea7b300000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a7060000000000000000000000000000000000000000000000000000000000000003', + }, + } + + const wrapper = shallow( + <TransactionAction + methodData={methodData} + transaction={transaction} + className="test-class" + />, + { context: { t } } + ) + + assert.ok(wrapper) + assert.equal(wrapper.find('.test-class').length, 1) + await wrapper.instance().getTransactionAction() + assert.equal(wrapper.state('transactionAction'), 'approve') + }) + + it('should render Accept Fulfillment', async () => { + const methodData = { + data: { + name: 'AcceptFulfillment', + params: [ + { type: 'address' }, + { type: 'uint256' }, + ], + }, + done: true, + error: null, + } + const transaction = { + id: 1, + status: 'confirmed', + submittedTime: 1534045442919, + time: 1534045440641, + txParams: { + from: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0x96', + to: 'approveAddress', + value: '0x2386f26fc10000', + data: '0x095ea7b300000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a7060000000000000000000000000000000000000000000000000000000000000003', + }, + } + + const wrapper = shallow( + <TransactionAction + methodData={methodData} + transaction={transaction} + className="test-class" + />, + { context: { t }} + ) + + assert.ok(wrapper) + assert.equal(wrapper.find('.test-class').length, 1) + await wrapper.instance().getTransactionAction() + assert.equal(wrapper.state('transactionAction'), ' Accept Fulfillment') + }) + }) +}) diff --git a/ui/app/components/transaction-action/transaction-action.component.js b/ui/app/components/transaction-action/transaction-action.component.js new file mode 100644 index 000000000..1de91cb71 --- /dev/null +++ b/ui/app/components/transaction-action/transaction-action.component.js @@ -0,0 +1,58 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { getTransactionActionKey } from '../../helpers/transactions.util' +import { camelCaseToCapitalize } from '../../helpers/common.util' + +export default class TransactionAction extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + className: PropTypes.string, + transaction: PropTypes.object, + methodData: PropTypes.object, + } + + state = { + transactionAction: '', + } + + componentDidMount () { + this.getTransactionAction() + } + + componentDidUpdate () { + this.getTransactionAction() + } + + async getTransactionAction () { + const { transactionAction } = this.state + const { transaction, methodData } = this.props + const { data, done } = methodData + const { name = '' } = data + + if (!done || transactionAction) { + return + } + + const actionKey = await getTransactionActionKey(transaction, data) + const action = actionKey + ? this.context.t(actionKey) + : camelCaseToCapitalize(name) + + this.setState({ transactionAction: action }) + } + + render () { + const { className, methodData: { done } } = this.props + const { transactionAction } = this.state + + return ( + <div className={classnames('transaction-action', className)}> + { (done && transactionAction) || '--' } + </div> + ) + } +} diff --git a/ui/app/components/transaction-activity-log/index.js b/ui/app/components/transaction-activity-log/index.js new file mode 100644 index 000000000..a33da15a3 --- /dev/null +++ b/ui/app/components/transaction-activity-log/index.js @@ -0,0 +1 @@ +export { default } from './transaction-activity-log.container' diff --git a/ui/app/components/transaction-activity-log/index.scss b/ui/app/components/transaction-activity-log/index.scss new file mode 100644 index 000000000..27f3006b3 --- /dev/null +++ b/ui/app/components/transaction-activity-log/index.scss @@ -0,0 +1,70 @@ +.transaction-activity-log { + &__card { + background: $white; + height: 100%; + } + + &__activities-container { + padding-top: 8px; + } + + &__activity { + padding: 4px 0; + display: flex; + flex-direction: row; + align-items: center; + position: relative; + + &::after { + content: ''; + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 6px; + border-right: 1px solid $scorpion; + } + + &:first-child::after { + height: 50%; + top: 50%; + } + + &:last-child::after { + height: 50%; + } + + &:first-child:last-child::after { + display: none; + } + } + + &__activity-icon { + width: 13px; + height: 13px; + margin-right: 6px; + border-radius: 50%; + background: $scorpion; + flex: 0 0 auto; + } + + &__activity-text { + color: $scorpion; + font-size: .75rem; + + @media screen and (min-width: $break-large) { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &__value { + display: inline; + font-weight: 500; + } + + b { + font-weight: 500; + } +} diff --git a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js new file mode 100644 index 000000000..8687dbbc7 --- /dev/null +++ b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js @@ -0,0 +1,35 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import TransactionActivityLog from '../transaction-activity-log.component' +import Card from '../../card' + +describe('TransactionActivityLog Component', () => { + it('should render properly', () => { + const transaction = { + history: [], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + const wrapper = shallow( + <TransactionActivityLog + transaction={transaction} + className="test-class" + />, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-activity-log')) + assert.ok(wrapper.hasClass('test-class')) + assert.equal(wrapper.find(Card).length, 1) + }) +}) diff --git a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.container.test.js b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.container.test.js new file mode 100644 index 000000000..85d56a6a2 --- /dev/null +++ b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.container.test.js @@ -0,0 +1,27 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' + +let mapStateToProps + +proxyquire('../transaction-activity-log.container.js', { + 'react-redux': { + connect: ms => { + mapStateToProps = ms + return () => ({}) + }, + }, +}) + +describe('TransactionActivityLog container', () => { + describe('mapStateToProps()', () => { + it('should return the correct props', () => { + const mockState = { + metamask: { + conversionRate: 280.45, + }, + } + + assert.deepEqual(mapStateToProps(mockState), { conversionRate: 280.45 }) + }) + }) +}) diff --git a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js new file mode 100644 index 000000000..586500408 --- /dev/null +++ b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js @@ -0,0 +1,208 @@ +import assert from 'assert' +import { getActivities } from '../transaction-activity-log.util' + +describe('getActivities', () => { + it('should return no activities for an empty history', () => { + const transaction = { + history: [], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + assert.deepEqual(getActivities(transaction), []) + }) + + it('should return activities for a transaction\'s history', () => { + const transaction = { + history: [ + { + id: 5559712943815343, + loadingDefaults: true, + metamaskNetworkId: '3', + status: 'unapproved', + time: 1535507561452, + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + }, + [ + { + op: 'replace', + path: '/loadingDefaults', + timestamp: 1535507561515, + value: false, + }, + { + op: 'add', + path: '/gasPriceSpecified', + value: true, + }, + { + op: 'add', + path: '/gasLimitSpecified', + value: true, + }, + { + op: 'add', + path: '/estimatedGas', + value: '0x5208', + }, + ], + [ + { + note: '#newUnapprovedTransaction - adding the origin', + op: 'add', + path: '/origin', + timestamp: 1535507561516, + value: 'MetaMask', + }, + [], + ], + [ + { + note: 'confTx: user approved transaction', + op: 'replace', + path: '/txParams/gasPrice', + timestamp: 1535664571504, + value: '0x77359400', + }, + ], + [ + { + note: 'txStateManager: setting status to approved', + op: 'replace', + path: '/status', + timestamp: 1535507564302, + value: 'approved', + }, + ], + [ + { + note: 'transactions#approveTransaction', + op: 'add', + path: '/txParams/nonce', + timestamp: 1535507564439, + value: '0xa4', + }, + { + op: 'add', + path: '/nonceDetails', + value: { + local: {}, + network: {}, + params: {}, + }, + }, + ], + [ + { + note: 'transactions#publishTransaction', + op: 'replace', + path: '/status', + timestamp: 1535507564518, + value: 'signed', + }, + { + op: 'add', + path: '/rawTx', + value: '0xf86b81a4843b9aca008252089450a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706872386f26fc10000802aa007b30119fc4fc5954fad727895b7e3ba80a78d197e95703cc603bcf017879151a01c50beda40ffaee541da9c05b9616247074f25f392800e0ad6c7a835d5366edf', + }, + ], + [], + [ + { + note: 'transactions#setTxHash', + op: 'add', + path: '/hash', + timestamp: 1535507564658, + value: '0x7acc4987b5c0dfa8d423798a8c561138259de1f98a62e3d52e7e83c0e0dd9fb7', + }, + ], + [ + { + note: 'txStateManager - add submitted time stamp', + op: 'add', + path: '/submittedTime', + timestamp: 1535507564660, + value: 1535507564660, + }, + ], + [ + { + note: 'txStateManager: setting status to submitted', + op: 'replace', + path: '/status', + timestamp: 1535507564665, + value: 'submitted', + }, + ], + [ + { + note: 'transactions/pending-tx-tracker#event: tx:block-update', + op: 'add', + path: '/firstRetryBlockNumber', + timestamp: 1535507575476, + value: '0x3bf624', + }, + ], + [ + { + note: 'txStateManager: setting status to confirmed', + op: 'replace', + path: '/status', + timestamp: 1535507615993, + value: 'confirmed', + }, + ], + ], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + const expectedResult = [ + { + 'eventKey': 'transactionCreated', + 'timestamp': 1535507561452, + 'value': '0x2386f26fc10000', + }, + { + 'eventKey': 'transactionUpdatedGas', + 'timestamp': 1535664571504, + 'value': '0x77359400', + }, + { + 'eventKey': 'transactionSubmitted', + 'timestamp': 1535507564665, + 'value': undefined, + }, + { + 'eventKey': 'transactionConfirmed', + 'timestamp': 1535507615993, + 'value': undefined, + }, + ] + + assert.deepEqual(getActivities(transaction), expectedResult) + }) +}) diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.component.js b/ui/app/components/transaction-activity-log/transaction-activity-log.component.js new file mode 100644 index 000000000..c4cf57d14 --- /dev/null +++ b/ui/app/components/transaction-activity-log/transaction-activity-log.component.js @@ -0,0 +1,91 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { getActivities } from './transaction-activity-log.util' +import Card from '../card' +import { getEthConversionFromWeiHex, getValueFromWeiHex } from '../../helpers/conversions.util' +import { ETH } from '../../constants/common' +import { formatDate } from '../../util' + +export default class TransactionActivityLog extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + transaction: PropTypes.object, + className: PropTypes.string, + conversionRate: PropTypes.number, + } + + state = { + activities: [], + } + + componentDidMount () { + this.setActivites() + } + + componentDidUpdate (prevProps) { + const { transaction: { history: prevHistory = [] } = {} } = prevProps + const { transaction: { history = [] } = {} } = this.props + + if (prevHistory.length !== history.length) { + this.setActivites() + } + } + + setActivites () { + const activities = getActivities(this.props.transaction) + this.setState({ activities }) + } + + renderActivity (activity, index) { + const { conversionRate } = this.props + const { eventKey, value, timestamp } = activity + const ethValue = index === 0 + ? `${getValueFromWeiHex({ + value, + toCurrency: ETH, + conversionRate, + numberOfDecimals: 6, + })} ${ETH}` + : getEthConversionFromWeiHex({ value, toCurrency: ETH, conversionRate }) + const formattedTimestamp = formatDate(timestamp) + const activityText = this.context.t(eventKey, [ethValue, formattedTimestamp]) + + return ( + <div + key={index} + className="transaction-activity-log__activity" + > + <div className="transaction-activity-log__activity-icon" /> + <div + className="transaction-activity-log__activity-text" + title={activityText} + > + { activityText } + </div> + </div> + ) + } + + render () { + const { t } = this.context + const { className } = this.props + const { activities } = this.state + + return ( + <div className={classnames('transaction-activity-log', className)}> + <Card + title={t('activityLog')} + className="transaction-activity-log__card" + > + <div className="transaction-activity-log__activities-container"> + { activities.map((activity, index) => this.renderActivity(activity, index)) } + </div> + </Card> + </div> + ) + } +} diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.container.js b/ui/app/components/transaction-activity-log/transaction-activity-log.container.js new file mode 100644 index 000000000..4c8b6d971 --- /dev/null +++ b/ui/app/components/transaction-activity-log/transaction-activity-log.container.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux' +import TransactionActivityLog from './transaction-activity-log.component' +import { conversionRateSelector } from '../../selectors' + +const mapStateToProps = state => { + return { + conversionRate: conversionRateSelector(state), + } +} + +export default connect(mapStateToProps)(TransactionActivityLog) diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.util.js b/ui/app/components/transaction-activity-log/transaction-activity-log.util.js new file mode 100644 index 000000000..32834ff47 --- /dev/null +++ b/ui/app/components/transaction-activity-log/transaction-activity-log.util.js @@ -0,0 +1,82 @@ +// path constants +const STATUS_PATH = '/status' +const GAS_PRICE_PATH = '/txParams/gasPrice' + +// status constants +const UNAPPROVED_STATUS = 'unapproved' +const SUBMITTED_STATUS = 'submitted' +const CONFIRMED_STATUS = 'confirmed' +const DROPPED_STATUS = 'dropped' + +// op constants +const REPLACE_OP = 'replace' + +// event constants +const TRANSACTION_CREATED_EVENT = 'transactionCreated' +const TRANSACTION_UPDATED_GAS_EVENT = 'transactionUpdatedGas' +const TRANSACTION_SUBMITTED_EVENT = 'transactionSubmitted' +const TRANSACTION_CONFIRMED_EVENT = 'transactionConfirmed' +const TRANSACTION_DROPPED_EVENT = 'transactionDropped' +const TRANSACTION_UPDATED_EVENT = 'transactionUpdated' + +const eventPathsHash = { + [STATUS_PATH]: true, + [GAS_PRICE_PATH]: true, +} + +const statusHash = { + [SUBMITTED_STATUS]: TRANSACTION_SUBMITTED_EVENT, + [CONFIRMED_STATUS]: TRANSACTION_CONFIRMED_EVENT, + [DROPPED_STATUS]: TRANSACTION_DROPPED_EVENT, +} + +function eventCreator (eventKey, timestamp, value) { + return { + eventKey, + timestamp, + value, + } +} + +export function getActivities (transaction) { + const { history = [] } = transaction + + return history.reduce((acc, base) => { + // First history item should be transaction creation + if (!Array.isArray(base) && base.status === UNAPPROVED_STATUS && base.txParams) { + const { time, txParams: { value } = {} } = base + return acc.concat(eventCreator(TRANSACTION_CREATED_EVENT, time, value)) + } else if (Array.isArray(base)) { + const events = [] + + base.forEach(entry => { + const { op, path, value, timestamp } = entry + + if (path in eventPathsHash && op === REPLACE_OP) { + switch (path) { + case STATUS_PATH: { + if (value in statusHash) { + events.push(eventCreator(statusHash[value], timestamp)) + } + + break + } + + case GAS_PRICE_PATH: { + events.push(eventCreator(TRANSACTION_UPDATED_GAS_EVENT, timestamp, value)) + break + } + + default: { + events.push(eventCreator(TRANSACTION_UPDATED_EVENT, timestamp)) + } + } + } + }) + + return acc.concat(events) + } + + return acc + }, []) +} diff --git a/ui/app/components/transaction-breakdown/index.js b/ui/app/components/transaction-breakdown/index.js new file mode 100644 index 000000000..c887f504f --- /dev/null +++ b/ui/app/components/transaction-breakdown/index.js @@ -0,0 +1 @@ +export { default } from './transaction-breakdown.component' diff --git a/ui/app/components/transaction-breakdown/index.scss b/ui/app/components/transaction-breakdown/index.scss new file mode 100644 index 000000000..1bb108943 --- /dev/null +++ b/ui/app/components/transaction-breakdown/index.scss @@ -0,0 +1,23 @@ +@import './transaction-breakdown-row/index'; + +.transaction-breakdown { + &__card { + background: $white; + height: 100%; + } + + &__row-title { + text-transform: capitalize; + } + + &__value { + text-align: end; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &--eth-total { + font-weight: 500; + } + } +} diff --git a/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js b/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js new file mode 100644 index 000000000..d18cd420c --- /dev/null +++ b/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js @@ -0,0 +1,37 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import TransactionBreakdown from '../transaction-breakdown.component' +import TransactionBreakdownRow from '../transaction-breakdown-row' +import Card from '../../card' + +describe('TransactionBreakdown Component', () => { + it('should render properly', () => { + const transaction = { + history: [], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + const wrapper = shallow( + <TransactionBreakdown + transaction={transaction} + className="test-class" + />, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-breakdown')) + assert.ok(wrapper.hasClass('test-class')) + assert.equal(wrapper.find(Card).length, 1) + assert.equal(wrapper.find(Card).find(TransactionBreakdownRow).length, 4) + }) +}) diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.js b/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.js new file mode 100644 index 000000000..557bf75fb --- /dev/null +++ b/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.js @@ -0,0 +1 @@ +export { default } from './transaction-breakdown-row.component' diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.scss b/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.scss new file mode 100644 index 000000000..8c73be1a6 --- /dev/null +++ b/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.scss @@ -0,0 +1,19 @@ +.transaction-breakdown-row { + font-size: .75rem; + color: $scorpion; + display: flex; + justify-content: space-between; + padding: 8px 0; + + &:not(:last-child) { + border-bottom: 1px solid #d8d8d8; + } + + &__title { + padding-right: 8px; + } + + &__value { + min-width: 0; + } +} diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js b/ui/app/components/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js new file mode 100644 index 000000000..c19399dbb --- /dev/null +++ b/ui/app/components/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js @@ -0,0 +1,39 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import TransactionBreakdownRow from '../transaction-breakdown-row.component' +import Button from '../../../button' + +describe('TransactionBreakdownRow Component', () => { + it('should render text properly', () => { + const wrapper = shallow( + <TransactionBreakdownRow + title="test" + className="test-class" + > + Test + </TransactionBreakdownRow>, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-breakdown-row')) + assert.equal(wrapper.find('.transaction-breakdown-row__title').text(), 'test') + assert.equal(wrapper.find('.transaction-breakdown-row__value').text(), 'Test') + }) + + it('should render components properly', () => { + const wrapper = shallow( + <TransactionBreakdownRow + title="test" + className="test-class" + > + <Button onClick={() => {}} >Button</Button> + </TransactionBreakdownRow>, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-breakdown-row')) + assert.equal(wrapper.find('.transaction-breakdown-row__title').text(), 'test') + assert.ok(wrapper.find('.transaction-breakdown-row__value').find(Button)) + }) +}) diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js b/ui/app/components/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js new file mode 100644 index 000000000..c11ff8efa --- /dev/null +++ b/ui/app/components/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js @@ -0,0 +1,26 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +export default class TransactionBreakdownRow extends PureComponent { + static propTypes = { + title: PropTypes.string, + children: PropTypes.node, + className: PropTypes.string, + } + + render () { + const { title, children, className } = this.props + + return ( + <div className={classnames('transaction-breakdown-row', className)}> + <div className="transaction-breakdown-row__title"> + { title } + </div> + <div className="transaction-breakdown-row__value"> + { children } + </div> + </div> + ) + } +} diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown.component.js b/ui/app/components/transaction-breakdown/transaction-breakdown.component.js new file mode 100644 index 000000000..bb6075e9f --- /dev/null +++ b/ui/app/components/transaction-breakdown/transaction-breakdown.component.js @@ -0,0 +1,82 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import TransactionBreakdownRow from './transaction-breakdown-row' +import Card from '../card' +import CurrencyDisplay from '../currency-display' +import HexToDecimal from '../hex-to-decimal' +import { ETH, GWEI } from '../../constants/common' +import { getHexGasTotal } from '../../helpers/confirm-transaction/util' +import { sumHexes } from '../../helpers/transactions.util' + +export default class TransactionBreakdown extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + transaction: PropTypes.object, + className: PropTypes.string, + } + + static defaultProps = { + transaction: {}, + } + + render () { + const { t } = this.context + const { transaction, className } = this.props + const { txParams: { gas, gasPrice, value } = {} } = transaction + const hexGasTotal = getHexGasTotal({ gasLimit: gas, gasPrice }) + const totalInHex = sumHexes(hexGasTotal, value) + + return ( + <div className={classnames('transaction-breakdown', className)}> + <Card + title={t('transaction')} + className="transaction-breakdown__card" + > + <TransactionBreakdownRow title={t('amount')}> + <CurrencyDisplay + className="transaction-breakdown__value" + currency={ETH} + value={value} + /> + </TransactionBreakdownRow> + <TransactionBreakdownRow + title={`${t('gasLimit')} (${t('units')})`} + className="transaction-breakdown__row-title" + > + <HexToDecimal + className="transaction-breakdown__value" + value={gas} + /> + </TransactionBreakdownRow> + <TransactionBreakdownRow title={t('gasPrice')}> + <CurrencyDisplay + className="transaction-breakdown__value" + currency={ETH} + denomination={GWEI} + value={gasPrice} + hideLabel + /> + </TransactionBreakdownRow> + <TransactionBreakdownRow title={t('total')}> + <div> + <CurrencyDisplay + className="transaction-breakdown__value transaction-breakdown__value--eth-total" + currency={ETH} + value={totalInHex} + numberOfDecimals={6} + /> + <CurrencyDisplay + className="transaction-breakdown__value" + value={totalInHex} + /> + </div> + </TransactionBreakdownRow> + </Card> + </div> + ) + } +} diff --git a/ui/app/components/transaction-list-item-details/index.js b/ui/app/components/transaction-list-item-details/index.js new file mode 100644 index 000000000..0e878d032 --- /dev/null +++ b/ui/app/components/transaction-list-item-details/index.js @@ -0,0 +1 @@ +export { default } from './transaction-list-item-details.component' diff --git a/ui/app/components/transaction-list-item-details/index.scss b/ui/app/components/transaction-list-item-details/index.scss new file mode 100644 index 000000000..54cf834cc --- /dev/null +++ b/ui/app/components/transaction-list-item-details/index.scss @@ -0,0 +1,49 @@ +.transaction-list-item-details { + &__header { + margin-bottom: 8px; + display: flex; + justify-content: space-between; + align-items: center; + } + + &__header-buttons { + display: flex; + flex-direction: row; + } + + &__header-button { + font-size: .625rem; + + &:not(:last-child) { + margin-right: 8px; + } + } + + &__sender-to-recipient-container { + margin-bottom: 8px; + } + + &__cards-container { + display: flex; + flex-direction: row; + + @media screen and (max-width: $break-small) { + flex-direction: column; + } + } + + &__transaction-breakdown { + flex: 1; + margin-right: 8px; + min-width: 0; + + @media screen and (max-width: $break-small) { + margin: 0 0 8px 0; + } + } + + &__transaction-activity-log { + flex: 2; + min-width: 0; + } +} diff --git a/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js b/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js new file mode 100644 index 000000000..f2bbe8789 --- /dev/null +++ b/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js @@ -0,0 +1,66 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import TransactionListItemDetails from '../transaction-list-item-details.component' +import Button from '../../button' +import SenderToRecipient from '../../sender-to-recipient' +import TransactionBreakdown from '../../transaction-breakdown' +import TransactionActivityLog from '../../transaction-activity-log' + +describe('TransactionListItemDetails Component', () => { + it('should render properly', () => { + const transaction = { + history: [], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + const wrapper = shallow( + <TransactionListItemDetails + transaction={transaction} + />, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-list-item-details')) + assert.equal(wrapper.find(Button).length, 1) + assert.equal(wrapper.find(SenderToRecipient).length, 1) + assert.equal(wrapper.find(TransactionBreakdown).length, 1) + assert.equal(wrapper.find(TransactionActivityLog).length, 1) + }) + + it('should render a retry button', () => { + const transaction = { + history: [], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + const wrapper = shallow( + <TransactionListItemDetails + transaction={transaction} + showRetry={true} + />, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-list-item-details')) + assert.equal(wrapper.find(Button).length, 2) + }) +}) diff --git a/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js b/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js new file mode 100644 index 000000000..13cb51349 --- /dev/null +++ b/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js @@ -0,0 +1,108 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import SenderToRecipient from '../sender-to-recipient' +import { CARDS_VARIANT } from '../sender-to-recipient/sender-to-recipient.constants' +import TransactionActivityLog from '../transaction-activity-log' +import TransactionBreakdown from '../transaction-breakdown' +import Button from '../button' +import prefixForNetwork from '../../../lib/etherscan-prefix-for-network' + +export default class TransactionListItemDetails extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + onCancel: PropTypes.func, + onRetry: PropTypes.func, + showCancel: PropTypes.bool, + showRetry: PropTypes.bool, + transaction: PropTypes.object, + } + + handleEtherscanClick = () => { + const { hash, metamaskNetworkId } = this.props.transaction + + const prefix = prefixForNetwork(metamaskNetworkId) + const etherscanUrl = `https://${prefix}etherscan.io/tx/${hash}` + global.platform.openWindow({ url: etherscanUrl }) + this.setState({ showTransactionDetails: true }) + } + + handleCancel = event => { + const { onCancel } = this.props + + event.stopPropagation() + onCancel() + } + + handleRetry = event => { + const { onRetry } = this.props + + event.stopPropagation() + onRetry() + } + + render () { + const { t } = this.context + const { transaction, showCancel, showRetry } = this.props + const { txParams: { to, from } = {} } = transaction + + return ( + <div className="transaction-list-item-details"> + <div className="transaction-list-item-details__header"> + <div>Details</div> + <div className="transaction-list-item-details__header-buttons"> + { + showRetry && ( + <Button + type="raised" + onClick={this.handleRetry} + className="transaction-list-item-details__header-button" + > + { t('speedUp') } + </Button> + ) + } + { + showCancel && ( + <Button + type="raised" + onClick={this.handleCancel} + className="transaction-list-item-details__header-button" + > + { t('cancel') } + </Button> + ) + } + <Button + type="raised" + onClick={this.handleEtherscanClick} + className="transaction-list-item-details__header-button" + > + <img src="/images/arrow-popout.svg" /> + </Button> + </div> + </div> + <div className="transaction-list-item-details__sender-to-recipient-container"> + <SenderToRecipient + variant={CARDS_VARIANT} + addressOnly + recipientAddress={to} + senderAddress={from} + /> + </div> + <div className="transaction-list-item-details__cards-container"> + <TransactionBreakdown + transaction={transaction} + className="transaction-list-item-details__transaction-breakdown" + /> + <TransactionActivityLog + transaction={transaction} + className="transaction-list-item-details__transaction-activity-log" + /> + </div> + </div> + ) + } +} diff --git a/ui/app/components/transaction-list-item/index.js b/ui/app/components/transaction-list-item/index.js new file mode 100644 index 000000000..697cc55e9 --- /dev/null +++ b/ui/app/components/transaction-list-item/index.js @@ -0,0 +1 @@ +export { default } from './transaction-list-item.container' diff --git a/ui/app/components/transaction-list-item/index.scss b/ui/app/components/transaction-list-item/index.scss new file mode 100644 index 000000000..9d694546b --- /dev/null +++ b/ui/app/components/transaction-list-item/index.scss @@ -0,0 +1,131 @@ +.transaction-list-item { + box-sizing: border-box; + min-height: 74px; + border-bottom: 1px solid $geyser; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + background: $white; + + &__grid { + cursor: pointer; + width: 100%; + padding: 16px 20px; + display: grid; + grid-template-columns: 45px 1fr 1fr 1fr; + grid-template-areas: + "identicon action status primary-amount" + "identicon nonce status secondary-amount"; + + @media screen and (max-width: $break-small) { + padding: 8px 20px 12px; + grid-template-columns: 45px 5fr 3fr; + grid-template-areas: + "nonce nonce nonce" + "identicon action primary-amount" + "identicon status secondary-amount"; + } + + &:hover { + background: rgba($alto, .2); + } + } + + &__identicon { + grid-area: identicon; + grid-row: 1 / span 2; + align-self: center; + + @media screen and (max-width: $break-small) { + grid-row: 2 / span 2; + } + } + + &__action { + text-transform: capitalize; + padding: 0 8px 2px 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + grid-area: action; + align-self: end; + } + + &__status { + grid-area: status; + grid-row: 1 / span 2; + align-self: center; + + @media screen and (max-width: $break-small) { + grid-row: 3; + } + } + + &__nonce { + font-size: .75rem; + color: #5e6064; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + grid-area: nonce; + align-self: start; + + @media screen and (max-width: $break-small) { + padding-bottom: 4px; + } + } + + &__amount { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &--primary { + text-align: end; + grid-area: primary-amount; + align-self: end; + + @media screen and (max-width: $break-small) { + padding-bottom: 2px; + } + } + + &--secondary { + text-align: end; + font-size: .75rem; + color: #5e6064; + grid-area: secondary-amount; + align-self: start; + } + } + + &__retry { + background: #d1edff; + border-radius: 12px; + font-size: .75rem; + padding: 4px 12px; + cursor: pointer; + margin-top: 8px; + + @media screen and (max-width: $break-small) { + font-size: .5rem; + } + } + + &__details-container { + padding: 8px 16px 16px; + background: #f3f4f7; + width: 100%; + } + + &__expander { + max-height: 0px; + width: 100%; + + &--show { + max-height: 1000px; + transition: max-height 700ms ease-out; + } + } +} diff --git a/ui/app/components/transaction-list-item/transaction-list-item.component.js b/ui/app/components/transaction-list-item/transaction-list-item.component.js new file mode 100644 index 000000000..c1c69f59b --- /dev/null +++ b/ui/app/components/transaction-list-item/transaction-list-item.component.js @@ -0,0 +1,200 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import Identicon from '../identicon' +import TransactionStatus from '../transaction-status' +import TransactionAction from '../transaction-action' +import CurrencyDisplay from '../currency-display' +import TokenCurrencyDisplay from '../token-currency-display' +import TransactionListItemDetails from '../transaction-list-item-details' +import { CONFIRM_TRANSACTION_ROUTE } from '../../routes' +import { UNAPPROVED_STATUS, TOKEN_METHOD_TRANSFER } from '../../constants/transactions' +import { ETH } from '../../constants/common' +import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../../app/scripts/lib/enums' + +export default class TransactionListItem extends PureComponent { + static propTypes = { + assetImages: PropTypes.object, + history: PropTypes.object, + methodData: PropTypes.object, + nonceAndDate: PropTypes.string, + retryTransaction: PropTypes.func, + setSelectedToken: PropTypes.func, + showCancelModal: PropTypes.func, + showCancel: PropTypes.bool, + showRetry: PropTypes.bool, + showTransactionDetailsModal: PropTypes.func, + token: PropTypes.object, + tokenData: PropTypes.object, + transaction: PropTypes.object, + value: PropTypes.string, + } + + state = { + showTransactionDetails: false, + } + + handleClick = () => { + const { + transaction, + history, + showTransactionDetailsModal, + methodData, + showCancel, + showRetry, + } = this.props + const { id, status } = transaction + const { showTransactionDetails } = this.state + const windowType = window.METAMASK_UI_TYPE + + if (status === UNAPPROVED_STATUS) { + history.push(`${CONFIRM_TRANSACTION_ROUTE}/${id}`) + return + } + + if (windowType === ENVIRONMENT_TYPE_FULLSCREEN) { + this.setState({ showTransactionDetails: !showTransactionDetails }) + } else { + showTransactionDetailsModal({ + transaction, + onRetry: this.handleRetry, + showRetry: showRetry && methodData.done, + onCancel: this.handleCancel, + showCancel, + }) + } + } + + handleCancel = () => { + const { transaction: { id, txParams: { gasPrice } } = {}, showCancelModal } = this.props + showCancelModal(id, gasPrice) + } + + handleRetry = () => { + const { + transaction: { txParams: { to } = {} }, + methodData: { name } = {}, + setSelectedToken, + } = this.props + + if (name === TOKEN_METHOD_TRANSFER) { + setSelectedToken(to) + } + + return this.resubmit() + } + + resubmit () { + const { transaction: { id }, retryTransaction, history } = this.props + return retryTransaction(id) + .then(id => history.push(`${CONFIRM_TRANSACTION_ROUTE}/${id}`)) + } + + renderPrimaryCurrency () { + const { token, transaction: { txParams: { data } = {} } = {}, value } = this.props + + return token + ? ( + <TokenCurrencyDisplay + className="transaction-list-item__amount transaction-list-item__amount--primary" + token={token} + transactionData={data} + prefix="-" + /> + ) : ( + <CurrencyDisplay + className="transaction-list-item__amount transaction-list-item__amount--primary" + value={value} + prefix="-" + numberOfDecimals={2} + currency={ETH} + /> + ) + } + + renderSecondaryCurrency () { + const { token, value } = this.props + + return token + ? null + : ( + <CurrencyDisplay + className="transaction-list-item__amount transaction-list-item__amount--secondary" + prefix="-" + value={value} + /> + ) + } + + render () { + const { + assetImages, + methodData, + nonceAndDate, + showCancel, + showRetry, + tokenData, + transaction, + } = this.props + const { txParams = {} } = transaction + const { showTransactionDetails } = this.state + const toAddress = tokenData + ? tokenData.params && tokenData.params[0] && tokenData.params[0].value || txParams.to + : txParams.to + + return ( + <div className="transaction-list-item"> + <div + className="transaction-list-item__grid" + onClick={this.handleClick} + > + <Identicon + className="transaction-list-item__identicon" + address={toAddress} + diameter={34} + image={assetImages[toAddress]} + /> + <TransactionAction + transaction={transaction} + methodData={methodData} + className="transaction-list-item__action" + /> + <div + className="transaction-list-item__nonce" + title={nonceAndDate} + > + { nonceAndDate } + </div> + <TransactionStatus + className="transaction-list-item__status" + statusKey={transaction.status} + title={( + (transaction.err && transaction.err.rpc) + ? transaction.err.rpc.message + : transaction.err && transaction.err.message + )} + /> + { this.renderPrimaryCurrency() } + { this.renderSecondaryCurrency() } + </div> + <div className={classnames('transaction-list-item__expander', { + 'transaction-list-item__expander--show': showTransactionDetails, + })}> + { + showTransactionDetails && ( + <div className="transaction-list-item__details-container"> + <TransactionListItemDetails + transaction={transaction} + onRetry={this.handleRetry} + showRetry={showRetry && methodData.done} + onCancel={this.handleCancel} + showCancel={showCancel} + /> + </div> + ) + } + </div> + </div> + ) + } +} diff --git a/ui/app/components/transaction-list-item/transaction-list-item.container.js b/ui/app/components/transaction-list-item/transaction-list-item.container.js new file mode 100644 index 000000000..72f5f5d61 --- /dev/null +++ b/ui/app/components/transaction-list-item/transaction-list-item.container.js @@ -0,0 +1,48 @@ +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { compose } from 'recompose' +import withMethodData from '../../higher-order-components/with-method-data' +import TransactionListItem from './transaction-list-item.component' +import { setSelectedToken, retryTransaction, showModal } from '../../actions' +import { hexToDecimal } from '../../helpers/conversions.util' +import { getTokenData } from '../../helpers/transactions.util' +import { formatDate } from '../../util' + +const mapStateToProps = (state, ownProps) => { + const { transaction: { txParams: { value, nonce, data } = {}, time } = {} } = ownProps + + const tokenData = data && getTokenData(data) + const nonceAndDate = nonce ? `#${hexToDecimal(nonce)} - ${formatDate(time)}` : formatDate(time) + + return { + value, + nonceAndDate, + tokenData, + } +} + +const mapDispatchToProps = dispatch => { + return { + setSelectedToken: tokenAddress => dispatch(setSelectedToken(tokenAddress)), + retryTransaction: transactionId => dispatch(retryTransaction(transactionId)), + showCancelModal: (transactionId, originalGasPrice) => { + return dispatch(showModal({ name: 'CANCEL_TRANSACTION', transactionId, originalGasPrice })) + }, + showTransactionDetailsModal: ({ transaction, onRetry, showRetry, onCancel, showCancel }) => { + return dispatch(showModal({ + name: 'TRANSACTION_DETAILS', + transaction, + onRetry, + showRetry, + onCancel, + showCancel, + })) + }, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps), + withMethodData, +)(TransactionListItem) diff --git a/ui/app/components/transaction-list/index.js b/ui/app/components/transaction-list/index.js new file mode 100644 index 000000000..688994367 --- /dev/null +++ b/ui/app/components/transaction-list/index.js @@ -0,0 +1 @@ +export { default } from './transaction-list.container' diff --git a/ui/app/components/transaction-list/index.scss b/ui/app/components/transaction-list/index.scss new file mode 100644 index 000000000..777f701f9 --- /dev/null +++ b/ui/app/components/transaction-list/index.scss @@ -0,0 +1,49 @@ +.transaction-list { + display: flex; + flex-direction: column; + flex: 1; + overflow-y: hidden; + margin-top: 8px; + border-top: 1px solid $geyser; + + &__completed-transactions { + display: flex; + flex-direction: column; + flex: 1; + } + + &__header { + flex: 0 0 auto; + font-size: .875rem; + color: $dusty-gray; + border-bottom: 1px solid $geyser; + padding: 8px 0 8px 20px; + + @media screen and (max-width: $break-small) { + padding: 8px 0 8px 16px; + } + } + + &__transactions { + flex: 1; + overflow-y: auto; + } + + &__pending-transactions { + margin-bottom: 16px; + } + + &__empty { + flex: 1; + display: grid; + grid-template-rows: 35% 1fr; + padding-top: 8px; + } + + &__empty-text { + grid-row-start: 2; + display: flex; + justify-content: center; + color: $silver; + } +} diff --git a/ui/app/components/transaction-list/transaction-list.component.js b/ui/app/components/transaction-list/transaction-list.component.js new file mode 100644 index 000000000..eef60186d --- /dev/null +++ b/ui/app/components/transaction-list/transaction-list.component.js @@ -0,0 +1,119 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import TransactionListItem from '../transaction-list-item' +import ShapeShiftTransactionListItem from '../shift-list-item' +import { TRANSACTION_TYPE_SHAPESHIFT } from '../../constants/transactions' + +export default class TransactionList extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static defaultProps = { + pendingTransactions: [], + completedTransactions: [], + transactionToRetry: {}, + } + + static propTypes = { + pendingTransactions: PropTypes.array, + completedTransactions: PropTypes.array, + transactionToRetry: PropTypes.object, + selectedToken: PropTypes.object, + updateNetworkNonce: PropTypes.func, + assetImages: PropTypes.object, + } + + componentDidMount () { + this.props.updateNetworkNonce() + } + + componentDidUpdate (prevProps) { + const { pendingTransactions: prevPendingTransactions = [] } = prevProps + const { pendingTransactions = [], updateNetworkNonce } = this.props + + if (pendingTransactions.length > prevPendingTransactions.length) { + updateNetworkNonce() + } + } + + shouldShowRetry = transaction => { + const { transactionToRetry } = this.props + const { id, submittedTime } = transaction + return id === transactionToRetry.id && Date.now() - submittedTime > 30000 + } + + renderTransactions () { + const { t } = this.context + const { pendingTransactions = [], completedTransactions = [] } = this.props + return ( + <div className="transaction-list__transactions"> + { + pendingTransactions.length > 0 && ( + <div className="transaction-list__pending-transactions"> + <div className="transaction-list__header"> + { `${t('queue')} (${pendingTransactions.length})` } + </div> + { + pendingTransactions.map((transaction, index) => ( + this.renderTransaction(transaction, index, true) + )) + } + </div> + ) + } + <div className="transaction-list__completed-transactions"> + <div className="transaction-list__header"> + { t('history') } + </div> + { + completedTransactions.length > 0 + ? completedTransactions.map((transaction, index) => ( + this.renderTransaction(transaction, index) + )) + : this.renderEmpty() + } + </div> + </div> + ) + } + + renderTransaction (transaction, index, showCancel) { + const { selectedToken, assetImages } = this.props + + return transaction.key === TRANSACTION_TYPE_SHAPESHIFT + ? ( + <ShapeShiftTransactionListItem + { ...transaction } + key={`shapeshift${index}`} + /> + ) : ( + <TransactionListItem + transaction={transaction} + key={transaction.id} + showRetry={this.shouldShowRetry(transaction)} + showCancel={showCancel} + token={selectedToken} + assetImages={assetImages} + /> + ) + } + + renderEmpty () { + return ( + <div className="transaction-list__empty"> + <div className="transaction-list__empty-text"> + { this.context.t('noTransactions') } + </div> + </div> + ) + } + + render () { + return ( + <div className="transaction-list"> + { this.renderTransactions() } + </div> + ) + } +} diff --git a/ui/app/components/transaction-list/transaction-list.container.js b/ui/app/components/transaction-list/transaction-list.container.js new file mode 100644 index 000000000..2e946c67d --- /dev/null +++ b/ui/app/components/transaction-list/transaction-list.container.js @@ -0,0 +1,51 @@ +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { compose } from 'recompose' +import TransactionList from './transaction-list.component' +import { + pendingTransactionsSelector, + submittedPendingTransactionsSelector, + completedTransactionsSelector, +} from '../../selectors/transactions' +import { getSelectedAddress, getAssetImages } from '../../selectors' +import { selectedTokenSelector } from '../../selectors/tokens' +import { getLatestSubmittedTxWithNonce } from '../../helpers/transactions.util' +import { updateNetworkNonce } from '../../actions' + +const mapStateToProps = state => { + const pendingTransactions = pendingTransactionsSelector(state) + const submittedPendingTransactions = submittedPendingTransactionsSelector(state) + const networkNonce = state.appState.networkNonce + + return { + completedTransactions: completedTransactionsSelector(state), + pendingTransactions, + transactionToRetry: getLatestSubmittedTxWithNonce(submittedPendingTransactions, networkNonce), + selectedToken: selectedTokenSelector(state), + selectedAddress: getSelectedAddress(state), + assetImages: getAssetImages(state), + } +} + +const mapDispatchToProps = dispatch => { + return { + updateNetworkNonce: address => dispatch(updateNetworkNonce(address)), + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { selectedAddress, ...restStateProps } = stateProps + const { updateNetworkNonce, ...restDispatchProps } = dispatchProps + + return { + ...restStateProps, + ...restDispatchProps, + ...ownProps, + updateNetworkNonce: () => updateNetworkNonce(selectedAddress), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps, mergeProps) +)(TransactionList) diff --git a/ui/app/components/transaction-status/index.js b/ui/app/components/transaction-status/index.js new file mode 100644 index 000000000..dece41e9c --- /dev/null +++ b/ui/app/components/transaction-status/index.js @@ -0,0 +1 @@ +export { default } from './transaction-status.component' diff --git a/ui/app/components/transaction-status/index.scss b/ui/app/components/transaction-status/index.scss new file mode 100644 index 000000000..35be550f7 --- /dev/null +++ b/ui/app/components/transaction-status/index.scss @@ -0,0 +1,28 @@ +.transaction-status { + height: 26px; + width: 81px; + border-radius: 4px; + background-color: #f0f0f0; + color: #5e6064; + font-size: .625rem; + text-transform: uppercase; + display: flex; + justify-content: center; + align-items: center; + + @media screen and (max-width: $break-small) { + height: 16px; + width: 70px; + font-size: .5rem; + } + + &--confirmed { + background-color: #eafad7; + color: #609a1c; + } + + &--approved, &--submitted { + background-color: #FFF2DB; + color: #CA810A; + } +}
\ No newline at end of file diff --git a/ui/app/components/transaction-status/transaction-status.component.js b/ui/app/components/transaction-status/transaction-status.component.js new file mode 100644 index 000000000..c22baf18a --- /dev/null +++ b/ui/app/components/transaction-status/transaction-status.component.js @@ -0,0 +1,59 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import Tooltip from '../tooltip-v2' +import { + UNAPPROVED_STATUS, + REJECTED_STATUS, + APPROVED_STATUS, + SIGNED_STATUS, + SUBMITTED_STATUS, + CONFIRMED_STATUS, + FAILED_STATUS, + DROPPED_STATUS, +} from '../../constants/transactions' + +const statusToClassNameHash = { + [UNAPPROVED_STATUS]: 'transaction-status--unapproved', + [REJECTED_STATUS]: 'transaction-status--rejected', + [APPROVED_STATUS]: 'transaction-status--approved', + [SIGNED_STATUS]: 'transaction-status--signed', + [SUBMITTED_STATUS]: 'transaction-status--submitted', + [CONFIRMED_STATUS]: 'transaction-status--confirmed', + [FAILED_STATUS]: 'transaction-status--failed', + [DROPPED_STATUS]: 'transaction-status--dropped', +} + +const statusToTextHash = { + [APPROVED_STATUS]: 'pending', + [SUBMITTED_STATUS]: 'pending', +} + +export default class TransactionStatus extends PureComponent { + static defaultProps = { + title: null, + } + + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + statusKey: PropTypes.string, + className: PropTypes.string, + title: PropTypes.string, + } + + render () { + const { className, statusKey, title } = this.props + const statusText = this.context.t(statusToTextHash[statusKey] || statusKey) + + return ( + <div className={classnames('transaction-status', className, statusToClassNameHash[statusKey])}> + <Tooltip position="top" title={title}> + { statusText } + </Tooltip> + </div> + ) + } +} diff --git a/ui/app/components/transaction-view-balance/index.js b/ui/app/components/transaction-view-balance/index.js new file mode 100644 index 000000000..8824737f7 --- /dev/null +++ b/ui/app/components/transaction-view-balance/index.js @@ -0,0 +1 @@ +export { default } from './transaction-view-balance.container' diff --git a/ui/app/components/transaction-view-balance/index.scss b/ui/app/components/transaction-view-balance/index.scss new file mode 100644 index 000000000..12045ab6d --- /dev/null +++ b/ui/app/components/transaction-view-balance/index.scss @@ -0,0 +1,76 @@ +.transaction-view-balance { + display: flex; + justify-content: space-between; + align-items: center; + flex: 1; + height: 54px; + + &__balance { + margin-left: 12px; + display: flex; + flex-direction: column; + + @media screen and (max-width: $break-small) { + align-items: center; + margin: 16px 0; + } + } + + &__token-balance { + margin-left: 12px; + font-size: 1.5rem; + + @media screen and (max-width: $break-small) { + margin-bottom: 12px; + font-size: 1.75rem; + } + } + + &__primary-balance { + font-size: 1.5rem; + + @media screen and (max-width: $break-small) { + margin-bottom: 12px; + font-size: 1.75rem; + } + } + + &__secondary-balance { + font-size: 1.15rem; + color: #a0a0a0; + } + + &__balance-container { + flex: 1; + display: flex; + flex-direction: row; + align-items: center; + + @media screen and (max-width: $break-small) { + flex-direction: column; + } + } + + &__buttons { + display: flex; + flex-direction: row; + + @media screen and (max-width: $break-small) { + margin-bottom: 16px; + } + } + + &__button { + min-width: initial; + width: 100px; + + &:not(:last-child) { + margin-right: 12px; + } + } + + @media screen and (max-width: $break-small) { + flex-direction: column; + height: initial + } +} diff --git a/ui/app/components/transaction-view-balance/tests/token-view-balance.component.test.js b/ui/app/components/transaction-view-balance/tests/token-view-balance.component.test.js new file mode 100644 index 000000000..bb95cb27e --- /dev/null +++ b/ui/app/components/transaction-view-balance/tests/token-view-balance.component.test.js @@ -0,0 +1,71 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import sinon from 'sinon' +import TokenBalance from '../../token-balance' +import CurrencyDisplay from '../../currency-display' +import { SEND_ROUTE } from '../../../routes' +import TransactionViewBalance from '../transaction-view-balance.component' + +const propsMethodSpies = { + showDepositModal: sinon.spy(), +} + +const historySpies = { + push: sinon.spy(), +} + +const t = (str1, str2) => str2 ? str1 + str2 : str1 + +describe('TransactionViewBalance Component', () => { + afterEach(() => { + propsMethodSpies.showDepositModal.resetHistory() + historySpies.push.resetHistory() + }) + + it('should render ETH balance properly', () => { + const wrapper = shallow(<TransactionViewBalance + showDepositModal={propsMethodSpies.showDepositModal} + history={historySpies} + network="3" + ethBalance={123} + fiatBalance={456} + currentCurrency="usd" + />, { context: { t } }) + + assert.equal(wrapper.find('.transaction-view-balance').length, 1) + assert.equal(wrapper.find('.transaction-view-balance__button').length, 2) + assert.equal(wrapper.find(CurrencyDisplay).length, 2) + + const buttons = wrapper.find('.transaction-view-balance__buttons') + assert.equal(propsMethodSpies.showDepositModal.callCount, 0) + buttons.childAt(0).simulate('click') + assert.equal(propsMethodSpies.showDepositModal.callCount, 1) + assert.equal(historySpies.push.callCount, 0) + buttons.childAt(1).simulate('click') + assert.equal(historySpies.push.callCount, 1) + assert.equal(historySpies.push.getCall(0).args[0], SEND_ROUTE) + }) + + it('should render token balance properly', () => { + const token = { + address: '0x35865238f0bec9d5ce6abff0fdaebe7b853dfcc5', + decimals: '2', + symbol: 'ABC', + } + + const wrapper = shallow(<TransactionViewBalance + showDepositModal={propsMethodSpies.showDepositModal} + history={historySpies} + network="3" + ethBalance={123} + fiatBalance={456} + currentCurrency="usd" + selectedToken={token} + />, { context: { t } }) + + assert.equal(wrapper.find('.transaction-view-balance').length, 1) + assert.equal(wrapper.find('.transaction-view-balance__button').length, 1) + assert.equal(wrapper.find(TokenBalance).length, 1) + }) +}) diff --git a/ui/app/components/transaction-view-balance/transaction-view-balance.component.js b/ui/app/components/transaction-view-balance/transaction-view-balance.component.js new file mode 100644 index 000000000..1b7a29c87 --- /dev/null +++ b/ui/app/components/transaction-view-balance/transaction-view-balance.component.js @@ -0,0 +1,96 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Button from '../button' +import Identicon from '../identicon' +import TokenBalance from '../token-balance' +import CurrencyDisplay from '../currency-display' +import { SEND_ROUTE } from '../../routes' +import { ETH } from '../../constants/common' + +export default class TransactionViewBalance extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + showDepositModal: PropTypes.func, + selectedToken: PropTypes.object, + history: PropTypes.object, + network: PropTypes.string, + balance: PropTypes.string, + assetImage: PropTypes.string, + } + + renderBalance () { + const { selectedToken, balance } = this.props + + return selectedToken + ? ( + <TokenBalance + token={selectedToken} + withSymbol + className="transaction-view-balance__token-balance" + /> + ) : ( + <div className="transaction-view-balance__balance"> + <CurrencyDisplay + className="transaction-view-balance__primary-balance" + value={balance} + currency={ETH} + numberOfDecimals={3} + /> + <CurrencyDisplay + className="transaction-view-balance__secondary-balance" + value={balance} + /> + </div> + ) + } + + renderButtons () { + const { t } = this.context + const { selectedToken, showDepositModal, history } = this.props + + return ( + <div className="transaction-view-balance__buttons"> + { + !selectedToken && ( + <Button + type="primary" + className="transaction-view-balance__button" + onClick={() => showDepositModal()} + > + { t('deposit') } + </Button> + ) + } + <Button + type="primary" + className="transaction-view-balance__button" + onClick={() => history.push(SEND_ROUTE)} + > + { t('send') } + </Button> + </div> + ) + } + + render () { + const { network, selectedToken, assetImage } = this.props + + return ( + <div className="transaction-view-balance"> + <div className="transaction-view-balance__balance-container"> + <Identicon + diameter={50} + address={selectedToken && selectedToken.address} + network={network} + image={assetImage} + /> + { this.renderBalance() } + </div> + { this.renderButtons() } + </div> + ) + } +} diff --git a/ui/app/components/transaction-view-balance/transaction-view-balance.container.js b/ui/app/components/transaction-view-balance/transaction-view-balance.container.js new file mode 100644 index 000000000..30c5cab16 --- /dev/null +++ b/ui/app/components/transaction-view-balance/transaction-view-balance.container.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { compose } from 'recompose' +import TransactionViewBalance from './transaction-view-balance.component' +import { getSelectedToken, getSelectedAddress, getSelectedTokenAssetImage } from '../../selectors' +import { showModal } from '../../actions' + +const mapStateToProps = state => { + const selectedAddress = getSelectedAddress(state) + const { metamask: { network, accounts } } = state + const account = accounts[selectedAddress] + const { balance } = account + + return { + selectedToken: getSelectedToken(state), + network, + balance, + assetImage: getSelectedTokenAssetImage(state), + } +} + +const mapDispatchToProps = dispatch => { + return { + showDepositModal: () => dispatch(showModal({ name: 'DEPOSIT_ETHER' })), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(TransactionViewBalance) diff --git a/ui/app/components/transaction-view/index.js b/ui/app/components/transaction-view/index.js new file mode 100644 index 000000000..9eb0c3c83 --- /dev/null +++ b/ui/app/components/transaction-view/index.js @@ -0,0 +1 @@ +export { default } from './transaction-view.component' diff --git a/ui/app/components/transaction-view/index.scss b/ui/app/components/transaction-view/index.scss new file mode 100644 index 000000000..af9771ce0 --- /dev/null +++ b/ui/app/components/transaction-view/index.scss @@ -0,0 +1,27 @@ +.transaction-view { + flex: 1 1 66.5%; + background: $white; + min-width: 0; + display: flex; + flex-direction: column; + + &__balance-wrapper { + @media screen and (max-width: $break-small) { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + flex: 0 0 auto; + padding-top: 16px; + } + + @media screen and (min-width: $break-large) { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + margin: 2.3em 2.37em .8em; + flex: 0 0 auto; + } + } +} diff --git a/ui/app/components/transaction-view/transaction-view.component.js b/ui/app/components/transaction-view/transaction-view.component.js new file mode 100644 index 000000000..7014ca173 --- /dev/null +++ b/ui/app/components/transaction-view/transaction-view.component.js @@ -0,0 +1,27 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Media from 'react-media' +import MenuBar from '../menu-bar' +import TransactionViewBalance from '../transaction-view-balance' +import TransactionList from '../transaction-list' + +export default class TransactionView extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + render () { + return ( + <div className="transaction-view"> + <Media + query="(max-width: 575px)" + render={() => <MenuBar />} + /> + <div className="transaction-view__balance-wrapper"> + <TransactionViewBalance /> + </div> + <TransactionList /> + </div> + ) + } +} diff --git a/ui/app/components/tx-list-item.js b/ui/app/components/tx-list-item.js deleted file mode 100644 index 474d62638..000000000 --- a/ui/app/components/tx-list-item.js +++ /dev/null @@ -1,356 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const { compose } = require('recompose') -const { withRouter } = require('react-router-dom') -const h = require('react-hyperscript') -const connect = require('react-redux').connect -const inherits = require('util').inherits -const classnames = require('classnames') -const abi = require('human-standard-token-abi') -const abiDecoder = require('abi-decoder') -abiDecoder.addABI(abi) -const Identicon = require('./identicon') -const contractMap = require('eth-contract-metadata') -const { checksumAddress } = require('../util') - -const actions = require('../actions') -const { conversionUtil, multiplyCurrencies } = require('../conversion-util') -const { calcTokenAmount } = require('../token-util') - -const { getCurrentCurrency } = require('../selectors') -const { CONFIRM_TRANSACTION_ROUTE } = require('../routes') - -TxListItem.contextTypes = { - t: PropTypes.func, -} - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(TxListItem) - -function mapStateToProps (state) { - return { - tokens: state.metamask.tokens, - currentCurrency: getCurrentCurrency(state), - contractExchangeRates: state.metamask.contractExchangeRates, - selectedAddressTxList: state.metamask.selectedAddressTxList, - networkNonce: state.appState.networkNonce, - } -} - -function mapDispatchToProps (dispatch) { - return { - setSelectedToken: tokenAddress => dispatch(actions.setSelectedToken(tokenAddress)), - retryTransaction: transactionId => dispatch(actions.retryTransaction(transactionId)), - } -} - -inherits(TxListItem, Component) -function TxListItem () { - Component.call(this) - - this.state = { - total: null, - fiatTotal: null, - isTokenTx: null, - } - - this.unmounted = false -} - -TxListItem.prototype.componentDidMount = async function () { - const { txParams = {} } = this.props - - const decodedData = txParams.data && abiDecoder.decodeMethod(txParams.data) - const { name: txDataName } = decodedData || {} - const isTokenTx = txDataName === 'transfer' - - const { total, fiatTotal } = isTokenTx - ? await this.getSendTokenTotal() - : this.getSendEtherTotal() - - if (this.unmounted) { - return - } - this.setState({ total, fiatTotal, isTokenTx }) -} - -TxListItem.prototype.componentWillUnmount = function () { - this.unmounted = true -} - -TxListItem.prototype.getAddressText = function () { - const { - address, - txParams = {}, - isMsg, - } = this.props - - const decodedData = txParams.data && abiDecoder.decodeMethod(txParams.data) - const { name: txDataName, params = [] } = decodedData || {} - const { value } = params[0] || {} - const checksummedAddress = checksumAddress(address) - const checksummedValue = checksumAddress(value) - - let addressText - if (txDataName === 'transfer' || address) { - const addressToRender = txDataName === 'transfer' ? checksummedValue : checksummedAddress - addressText = `${addressToRender.slice(0, 10)}...${addressToRender.slice(-4)}` - } else if (isMsg) { - addressText = this.context.t('sigRequest') - } else { - addressText = this.context.t('contractDeployment') - } - - return addressText -} - -TxListItem.prototype.getSendEtherTotal = function () { - const { - transactionAmount, - conversionRate, - address, - currentCurrency, - } = this.props - - if (!address) { - return {} - } - - const totalInFiat = conversionUtil(transactionAmount, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - fromCurrency: 'ETH', - toCurrency: currentCurrency, - fromDenomination: 'WEI', - numberOfDecimals: 2, - conversionRate, - }) - const totalInETH = conversionUtil(transactionAmount, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - fromCurrency: 'ETH', - toCurrency: 'ETH', - fromDenomination: 'WEI', - conversionRate, - numberOfDecimals: 6, - }) - - return { - total: `${totalInETH} ETH`, - fiatTotal: `${totalInFiat} ${currentCurrency.toUpperCase()}`, - } -} - -TxListItem.prototype.getTokenInfo = async function () { - const { txParams = {}, tokenInfoGetter, tokens } = this.props - const toAddress = txParams.to - - let decimals - let symbol - - ({ decimals, symbol } = tokens.filter(({ address }) => address === toAddress)[0] || {}) - - if (!decimals && !symbol) { - ({ decimals, symbol } = contractMap[toAddress] || {}) - } - - if (!decimals && !symbol) { - ({ decimals, symbol } = await tokenInfoGetter(toAddress)) - } - - return { decimals, symbol, address: toAddress } -} - -TxListItem.prototype.getSendTokenTotal = async function () { - const { - txParams = {}, - conversionRate, - contractExchangeRates, - currentCurrency, - } = this.props - - const decodedData = txParams.data && abiDecoder.decodeMethod(txParams.data) - const { params = [] } = decodedData || {} - const { value } = params[1] || {} - const { decimals, symbol, address } = await this.getTokenInfo() - const total = calcTokenAmount(value, decimals) - - let tokenToFiatRate - let totalInFiat - - if (contractExchangeRates[address]) { - tokenToFiatRate = multiplyCurrencies( - contractExchangeRates[address], - conversionRate - ) - - totalInFiat = conversionUtil(total, { - fromNumericBase: 'dec', - toNumericBase: 'dec', - fromCurrency: symbol, - toCurrency: currentCurrency, - numberOfDecimals: 2, - conversionRate: tokenToFiatRate, - }) - } - - const showFiat = Boolean(totalInFiat) && currentCurrency.toUpperCase() !== symbol - - return { - total: `${total} ${symbol}`, - fiatTotal: showFiat && `${totalInFiat} ${currentCurrency.toUpperCase()}`, - } -} - -TxListItem.prototype.showRetryButton = function () { - const { - transactionSubmittedTime, - selectedAddressTxList, - transactionId, - txParams, - networkNonce, - } = this.props - if (!txParams) { - return false - } - let currentTxSharesEarliestNonce = false - const currentNonce = txParams.nonce - const currentNonceTxs = selectedAddressTxList.filter(tx => tx.txParams.nonce === currentNonce) - const currentNonceSubmittedTxs = currentNonceTxs.filter(tx => tx.status === 'submitted') - const currentSubmittedTxs = selectedAddressTxList.filter(tx => tx.status === 'submitted') - const lastSubmittedTxWithCurrentNonce = currentNonceSubmittedTxs[currentNonceSubmittedTxs.length - 1] - const currentTxIsLatestWithNonce = lastSubmittedTxWithCurrentNonce && - lastSubmittedTxWithCurrentNonce.id === transactionId - if (currentSubmittedTxs.length > 0) { - currentTxSharesEarliestNonce = currentNonce === networkNonce - } - - return currentTxSharesEarliestNonce && currentTxIsLatestWithNonce && Date.now() - transactionSubmittedTime > 30000 -} - -TxListItem.prototype.setSelectedToken = function (tokenAddress) { - this.props.setSelectedToken(tokenAddress) -} - -TxListItem.prototype.resubmit = function () { - const { transactionId } = this.props - this.props.retryTransaction(transactionId) - .then(id => this.props.history.push(`${CONFIRM_TRANSACTION_ROUTE}/${id}`)) -} - -TxListItem.prototype.render = function () { - const { - transactionStatus, - onClick, - transactionId, - dateString, - address, - className, - txParams, - } = this.props - const { total, fiatTotal, isTokenTx } = this.state - - return h(`div${className || ''}`, { - key: transactionId, - onClick: () => onClick && onClick(transactionId), - }, [ - h(`div.flex-column.tx-list-item-wrapper`, {}, [ - - h('div.tx-list-date-wrapper', { - style: {}, - }, [ - h('span.tx-list-date', {}, [ - dateString, - ]), - ]), - - h('div.flex-row.tx-list-content-wrapper', { - style: {}, - }, [ - - h('div.tx-list-identicon-wrapper', { - style: {}, - }, [ - h(Identicon, { - address, - diameter: 28, - }), - ]), - - h('div.tx-list-account-and-status-wrapper', {}, [ - h('div.tx-list-account-wrapper', { - style: {}, - }, [ - h('span.tx-list-account', {}, [ - this.getAddressText(address), - ]), - ]), - - h('div.tx-list-status-wrapper', { - style: {}, - }, [ - h('span', { - className: classnames('tx-list-status', { - 'tx-list-status--rejected': transactionStatus === 'rejected', - 'tx-list-status--failed': transactionStatus === 'failed', - 'tx-list-status--dropped': transactionStatus === 'dropped', - }), - }, - this.txStatusIndicator(), - ), - ]), - ]), - - h('div.flex-column.tx-list-details-wrapper', { - style: {}, - }, [ - - h('span.tx-list-value', total), - - fiatTotal && h('span.tx-list-fiat-value', fiatTotal), - - ]), - ]), - - this.showRetryButton() && h('.tx-list-item-retry-container', { - onClick: (event) => { - event.stopPropagation() - if (isTokenTx) { - this.setSelectedToken(txParams.to) - } - this.resubmit() - }, - }, [ - h('span', 'Taking too long? Increase the gas price on your transaction'), - ]), - - ]), // holding on icon from design - ]) -} - -TxListItem.prototype.txStatusIndicator = function () { - const { transactionStatus } = this.props - - let name - - if (transactionStatus === 'unapproved') { - name = this.context.t('unapproved') - } else if (transactionStatus === 'rejected') { - name = this.context.t('rejected') - } else if (transactionStatus === 'approved') { - name = this.context.t('approved') - } else if (transactionStatus === 'signed') { - name = this.context.t('signed') - } else if (transactionStatus === 'submitted') { - name = this.context.t('submitted') - } else if (transactionStatus === 'confirmed') { - name = this.context.t('confirmed') - } else if (transactionStatus === 'failed') { - name = this.context.t('failed') - } else if (transactionStatus === 'dropped') { - name = this.context.t('dropped') - } - return name -} diff --git a/ui/app/components/tx-list.js b/ui/app/components/tx-list.js deleted file mode 100644 index d8c4a9d19..000000000 --- a/ui/app/components/tx-list.js +++ /dev/null @@ -1,171 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const connect = require('react-redux').connect -const h = require('react-hyperscript') -const inherits = require('util').inherits -const prefixForNetwork = require('../../lib/etherscan-prefix-for-network') -const selectors = require('../selectors') -const TxListItem = require('./tx-list-item') -const ShiftListItem = require('./shift-list-item') -const { formatDate } = require('../util') -const { showConfTxPage, updateNetworkNonce } = require('../actions') -const classnames = require('classnames') -const { tokenInfoGetter } = require('../token-util') -const { withRouter } = require('react-router-dom') -const { compose } = require('recompose') -const { CONFIRM_TRANSACTION_ROUTE } = require('../routes') - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(TxList) - -TxList.contextTypes = { - t: PropTypes.func, -} - -function mapStateToProps (state) { - return { - txsToRender: selectors.transactionsSelector(state), - conversionRate: selectors.conversionRateSelector(state), - selectedAddress: selectors.getSelectedAddress(state), - } -} - -function mapDispatchToProps (dispatch) { - return { - showConfTxPage: ({ id }) => dispatch(showConfTxPage({ id })), - updateNetworkNonce: (address) => dispatch(updateNetworkNonce(address)), - } -} - -inherits(TxList, Component) -function TxList () { - Component.call(this) -} - -TxList.prototype.componentWillMount = function () { - this.tokenInfoGetter = tokenInfoGetter() - this.props.updateNetworkNonce(this.props.selectedAddress) -} - -TxList.prototype.componentDidUpdate = function (prevProps) { - const oldTxsToRender = prevProps.txsToRender - const { - txsToRender: newTxsToRender, - selectedAddress, - updateNetworkNonce, - } = this.props - - if (newTxsToRender.length > oldTxsToRender.length) { - updateNetworkNonce(selectedAddress) - } -} - -TxList.prototype.render = function () { - return h('div.flex-column', [ - h('div.flex-row.tx-list-header-wrapper', [ - h('div.flex-row.tx-list-header', [ - h('div', this.context.t('transactions')), - ]), - ]), - h('div.flex-column.tx-list-container', {}, [ - this.renderTransaction(), - ]), - ]) -} - -TxList.prototype.renderTransaction = function () { - const { txsToRender, conversionRate } = this.props - - return txsToRender.length - ? txsToRender.map((transaction, i) => this.renderTransactionListItem(transaction, conversionRate, i)) - : [h( - 'div.tx-list-item.tx-list-item--empty', - { key: 'tx-list-none' }, - [ this.context.t('noTransactions') ], - )] -} - -// TODO: Consider moving TxListItem into a separate component -TxList.prototype.renderTransactionListItem = function (transaction, conversionRate, index) { - // console.log({transaction}) - // refer to transaction-list.js:line 58 - - if (transaction.key === 'shapeshift') { - return h(ShiftListItem, { ...transaction, key: `shapeshift${index}` }) - } - - const props = { - dateString: formatDate(transaction.time), - address: transaction.txParams && transaction.txParams.to, - transactionStatus: transaction.status, - transactionAmount: transaction.txParams && transaction.txParams.value, - transactionId: transaction.id, - transactionHash: transaction.hash, - transactionNetworkId: transaction.metamaskNetworkId, - transactionSubmittedTime: transaction.submittedTime, - } - - const { - address, - transactionStatus, - transactionAmount, - dateString, - transactionId, - transactionHash, - transactionNetworkId, - transactionSubmittedTime, - } = props - const { history } = this.props - - const opts = { - key: transactionId || transactionHash, - txParams: transaction.txParams, - isMsg: Boolean(transaction.msgParams), - transactionStatus, - transactionId, - dateString, - address, - transactionAmount, - transactionHash, - conversionRate, - tokenInfoGetter: this.tokenInfoGetter, - transactionSubmittedTime, - } - - const isUnapproved = transactionStatus === 'unapproved' - - if (isUnapproved) { - opts.onClick = () => { - this.props.showConfTxPage({ id: transactionId }) - history.push(CONFIRM_TRANSACTION_ROUTE) - } - opts.transactionStatus = this.context.t('notStarted') - } else if (transactionHash) { - opts.onClick = () => this.view(transactionHash, transactionNetworkId) - } - - opts.className = classnames('.tx-list-item', { - '.tx-list-pending-item-container': isUnapproved, - '.tx-list-clickable': Boolean(transactionHash) || isUnapproved, - }) - - return h(TxListItem, opts) -} - -TxList.prototype.view = function (txHash, network) { - const url = etherscanLinkFor(txHash, network) - if (url) { - navigateTo(url) - } -} - -function navigateTo (url) { - global.platform.openWindow({ url }) -} - -function etherscanLinkFor (txHash, network) { - const prefix = prefixForNetwork(network) - return `https://${prefix}etherscan.io/tx/${txHash}` -} diff --git a/ui/app/components/tx-view.js b/ui/app/components/tx-view.js deleted file mode 100644 index 654090da6..000000000 --- a/ui/app/components/tx-view.js +++ /dev/null @@ -1,156 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const connect = require('react-redux').connect -const h = require('react-hyperscript') -const inherits = require('util').inherits -const { withRouter } = require('react-router-dom') -const { compose } = require('recompose') -const actions = require('../actions') -const selectors = require('../selectors') -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') - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(TxView) - -TxView.contextTypes = { - t: PropTypes.func, -} - -function mapStateToProps (state) { - const sidebarOpen = state.appState.sidebarOpen - const isMascara = state.appState.isMascara - - const identities = state.metamask.identities - const accounts = state.metamask.accounts - const network = state.metamask.network - const selectedTokenAddress = state.metamask.selectedTokenAddress - const selectedAddress = state.metamask.selectedAddress || Object.keys(accounts)[0] - const checksumAddress = toChecksumAddress(selectedAddress) - const identity = identities[selectedAddress] - - return { - sidebarOpen, - selectedAddress, - checksumAddress, - selectedTokenAddress, - selectedToken: selectors.getSelectedToken(state), - identity, - network, - isMascara, - } -} - -function mapDispatchToProps (dispatch) { - return { - showSidebar: () => { dispatch(actions.showSidebar()) }, - hideSidebar: () => { dispatch(actions.hideSidebar()) }, - showModal: (payload) => { dispatch(actions.showModal(payload)) }, - showSendPage: () => { dispatch(actions.showSendPage()) }, - showSendTokenPage: () => { dispatch(actions.showSendTokenPage()) }, - } -} - -inherits(TxView, Component) -function TxView () { - Component.call(this) -} - -TxView.prototype.renderHeroBalance = function () { - const { selectedToken } = this.props - - return h('div.hero-balance', {}, [ - - h(BalanceComponent, { token: selectedToken }), - - this.renderButtons(), - ]) -} - -TxView.prototype.renderButtons = function () { - const {selectedToken, showModal, history } = this.props - - return !selectedToken - ? ( - h('div.flex-row.flex-center.hero-balance-buttons', [ - h('button.btn-primary.hero-balance-button', { - onClick: () => showModal({ - name: 'DEPOSIT_ETHER', - }), - }, this.context.t('deposit')), - - h('button.btn-primary.hero-balance-button', { - style: { - marginLeft: '0.8em', - }, - onClick: () => history.push(SEND_ROUTE), - }, this.context.t('send')), - ]) - ) - : ( - h('div.flex-row.flex-center.hero-balance-buttons', [ - h('button.btn-primary.hero-balance-button', { - onClick: () => history.push(SEND_ROUTE), - }, this.context.t('send')), - ]) - ) -} - -TxView.prototype.render = function () { - const { hideSidebar, isMascara, showSidebar, sidebarOpen } = this.props - const { t } = this.context - - return h('div.tx-view.flex-column', { - style: {}, - }, [ - - h('div.flex-row.phone-visible', { - style: { - justifyContent: 'center', - alignItems: 'center', - flex: '0 0 auto', - marginBottom: '16px', - padding: '5px', - borderBottom: '1px solid #e5e5e5', - }, - }, [ - - 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(Tooltip, { - title: t('openInTab'), - position: 'bottom', - }, [ - h('div.open-in-browser', { - onClick: () => global.platform.openExtensionInBrowser(), - }, [h('img', { src: 'images/popout.svg' })]), - ]), - ]), - - this.renderHeroBalance(), - - h(TxList), - - ]) -} diff --git a/ui/app/components/wallet-view.js b/ui/app/components/wallet-view.js index 8e092364c..064a6ab55 100644 --- a/ui/app/components/wallet-view.js +++ b/ui/app/components/wallet-view.js @@ -9,7 +9,7 @@ const classnames = require('classnames') const { checksumAddress } = require('../util') const Identicon = require('./identicon') // const AccountDropdowns = require('./dropdowns/index.js').AccountDropdowns -const Tooltip = require('./tooltip-v2.js') +const Tooltip = require('./tooltip-v2.js').default const copyToClipboard = require('copy-to-clipboard') const actions = require('../actions') const BalanceComponent = require('./balance-component') @@ -17,6 +17,8 @@ const TokenList = require('./token-list') const selectors = require('../selectors') const { ADD_TOKEN_ROUTE } = require('../routes') +import Button from './button' + module.exports = compose( withRouter, connect(mapStateToProps, mapDispatchToProps) @@ -26,11 +28,15 @@ WalletView.contextTypes = { t: PropTypes.func, } +WalletView.defaultProps = { + responsiveDisplayClassname: '', +} + function mapStateToProps (state) { return { network: state.metamask.network, - sidebarOpen: state.appState.sidebarOpen, + sidebarOpen: state.appState.sidebar.isOpen, identities: state.metamask.identities, accounts: state.metamask.accounts, tokens: state.metamask.tokens, @@ -131,8 +137,9 @@ WalletView.prototype.render = function () { } } - return h('div.wallet-view.flex-column' + (responsiveDisplayClassname || ''), { + return h('div.wallet-view.flex-column', { style: {}, + className: responsiveDisplayClassname, }, [ // TODO: Separate component: wallet account details @@ -194,7 +201,9 @@ WalletView.prototype.render = function () { h(TokenList), - h('button.btn-primary.wallet-view__add-token-button', { + h(Button, { + type: 'primary', + className: 'wallet-view__add-token-button', onClick: () => { history.push(ADD_TOKEN_ROUTE) sidebarOpen && hideSidebar() diff --git a/ui/app/conf-tx.js b/ui/app/conf-tx.js index 112ea6bca..0784a872e 100644 --- a/ui/app/conf-tx.js +++ b/ui/app/conf-tx.js @@ -104,7 +104,7 @@ ConfirmTxScreen.prototype.componentDidUpdate = function (prevProps) { if (prevTx && prevTx.status === 'dropped') { this.props.dispatch(actions.showModal({ name: 'TRANSACTION_CONFIRMED', - onHide: () => history.push(DEFAULT_ROUTE), + onSubmit: () => history.push(DEFAULT_ROUTE), })) return diff --git a/ui/app/constants/common.js b/ui/app/constants/common.js new file mode 100644 index 000000000..a20f6cc02 --- /dev/null +++ b/ui/app/constants/common.js @@ -0,0 +1,3 @@ +export const ETH = 'ETH' +export const GWEI = 'GWEI' +export const WEI = 'WEI' diff --git a/ui/app/constants/transactions.js b/ui/app/constants/transactions.js new file mode 100644 index 000000000..2dc061091 --- /dev/null +++ b/ui/app/constants/transactions.js @@ -0,0 +1,23 @@ +export const UNAPPROVED_STATUS = 'unapproved' +export const REJECTED_STATUS = 'rejected' +export const APPROVED_STATUS = 'approved' +export const SIGNED_STATUS = 'signed' +export const SUBMITTED_STATUS = 'submitted' +export const CONFIRMED_STATUS = 'confirmed' +export const FAILED_STATUS = 'failed' +export const DROPPED_STATUS = 'dropped' + +export const TOKEN_METHOD_TRANSFER = 'transfer' +export const TOKEN_METHOD_APPROVE = 'approve' +export const TOKEN_METHOD_TRANSFER_FROM = 'transferfrom' + +export const SEND_ETHER_ACTION_KEY = 'sentEther' +export const DEPLOY_CONTRACT_ACTION_KEY = 'contractDeployment' +export const APPROVE_ACTION_KEY = 'approve' +export const SEND_TOKEN_ACTION_KEY = 'sentTokens' +export const TRANSFER_FROM_ACTION_KEY = 'transferFrom' +export const SIGNATURE_REQUEST_KEY = 'signatureRequest' +export const UNKNOWN_FUNCTION_KEY = 'unknownFunction' +export const CANCEL_ATTEMPT_ACTION_KEY = 'cancelAttempt' + +export const TRANSACTION_TYPE_SHAPESHIFT = 'shapeshift' diff --git a/ui/app/conversion-util.js b/ui/app/conversion-util.js index 38f5f1c50..f271b5683 100644 --- a/ui/app/conversion-util.js +++ b/ui/app/conversion-util.js @@ -35,6 +35,7 @@ BigNumber.config({ // Big Number Constants const BIG_NUMBER_WEI_MULTIPLIER = new BigNumber('1000000000000000000') const BIG_NUMBER_GWEI_MULTIPLIER = new BigNumber('1000000000') +const BIG_NUMBER_ETH_MULTIPLIER = new BigNumber('1') // Individual Setters const convert = R.invoker(1, 'times') @@ -52,10 +53,12 @@ const toBigNumber = { const toNormalizedDenomination = { WEI: bigNumber => bigNumber.div(BIG_NUMBER_WEI_MULTIPLIER), GWEI: bigNumber => bigNumber.div(BIG_NUMBER_GWEI_MULTIPLIER), + ETH: bigNumber => bigNumber.div(BIG_NUMBER_ETH_MULTIPLIER), } const toSpecifiedDenomination = { WEI: bigNumber => bigNumber.times(BIG_NUMBER_WEI_MULTIPLIER).round(), GWEI: bigNumber => bigNumber.times(BIG_NUMBER_GWEI_MULTIPLIER).round(9), + ETH: bigNumber => bigNumber.times(BIG_NUMBER_ETH_MULTIPLIER).round(9), } const baseChange = { hex: n => n.toString(16), diff --git a/ui/app/css/itcss/components/account-details-dropdown.scss b/ui/app/css/itcss/components/account-details-dropdown.scss new file mode 100644 index 000000000..2a3007f83 --- /dev/null +++ b/ui/app/css/itcss/components/account-details-dropdown.scss @@ -0,0 +1,7 @@ +.account-details-dropdown { + width: 60%; + position: absolute; + top: 120px; + right: 15px; + z-index: 2000; +}
\ No newline at end of file diff --git a/ui/app/css/itcss/components/buttons.scss b/ui/app/css/itcss/components/buttons.scss index 34565767f..655188a3e 100644 --- a/ui/app/css/itcss/components/buttons.scss +++ b/ui/app/css/itcss/components/buttons.scss @@ -2,10 +2,7 @@ Buttons */ -.btn-default, -.btn-primary, -.btn-secondary, -.btn-confirm { +.button { height: 44px; background: $white; display: flex; @@ -79,6 +76,16 @@ background-color: $curious-blue; } +.btn-raised { + color: $curious-blue; + background-color: $white; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.08); + padding: 6px; + height: initial; + width: initial; + min-width: initial; +} + .btn--large { height: 54px; } diff --git a/ui/app/css/itcss/components/hero-balance.scss b/ui/app/css/itcss/components/hero-balance.scss deleted file mode 100644 index eba93ecb4..000000000 --- a/ui/app/css/itcss/components/hero-balance.scss +++ /dev/null @@ -1,130 +0,0 @@ -.hero-balance { - - @media screen and (max-width: $break-small) { - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: center; - flex: 0 0 auto; - padding-top: 16px; - } - - @media screen and (min-width: $break-large) { - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; - margin: 2.3em 2.37em .8em; - flex: 0 0 auto; - } - - .balance-container { - display: flex; - margin: 0; - justify-content: flex-start; - align-items: center; - - @media screen and (max-width: $break-small) { - flex-direction: column; - flex: 0 0 auto; - max-width: 100%; - } - - @media screen and (min-width: $break-large) { - flex-direction: row; - flex-grow: 3; - min-width: 0; - } - } - - .balance-display { - .token-amount { - color: $black; - max-width: 100%; - - .token-balance { - display: flex; - } - } - - @media screen and (max-width: $break-small) { - max-width: 100%; - text-align: center; - - .token-amount { - font-size: 1.75rem; - margin-top: 1rem; - - .token-balance { - flex-direction: column; - } - } - - .fiat-amount { - font-size: 115%; - margin-top: 8.5%; - color: #a0a0a0; - } - } - - @media screen and (min-width: $break-large) { - margin: 0 .8em; - justify-content: flex-start; - align-items: flex-start; - min-width: 0; - - .token-amount { - font-size: 1.5rem; - } - - .fiat-amount { - margin-top: .25%; - font-size: 105%; - } - } - - @media #{$sub-mid-size-breakpoint-range} { - margin-left: .4em; - margin-right: .4em; - justify-content: flex-start; - align-items: flex-start; - - .token-amount { - font-size: 1rem; - } - - .fiat-amount { - margin-top: .25%; - font-size: 1rem; - } - } - } - - .hero-balance-buttons { - - @media screen and (max-width: $break-small) { - width: 100%; - // height: 100px; // needed a round number to set the heights of the buttons inside - flex: 0 0 auto; - padding: 16px 0; - } - - @media screen and (min-width: $break-large) { - flex-grow: 2; - justify-content: flex-end; - } - } -} - -.hero-balance-button { - min-width: initial; - width: 6rem; - - @media #{$sub-mid-size-breakpoint-range} { - padding: .4rem; - width: 4rem; - display: flex; - flex: 1; - justify-content: center; - } -} diff --git a/ui/app/css/itcss/components/index.scss b/ui/app/css/itcss/components/index.scss index 96ad5fe64..63aa62eb3 100644 --- a/ui/app/css/itcss/components/index.scss +++ b/ui/app/css/itcss/components/index.scss @@ -1,7 +1,5 @@ @import './buttons.scss'; -@import './header.scss'; - @import './footer.scss'; @import './network.scss'; @@ -21,8 +19,6 @@ @import './loading-overlay.scss'; // Balances -@import './hero-balance.scss'; - @import './wallet-balance.scss'; // Tx List and Sections @@ -40,14 +36,14 @@ @import './gas-slider.scss'; -@import './settings.scss'; - @import './tab-bar.scss'; @import './simple-dropdown.scss'; @import './request-signature.scss'; +@import './account-details-dropdown.scss'; + @import './account-dropdown-mini.scss'; @import './editable-label.scss'; diff --git a/ui/app/css/itcss/components/loading-overlay.scss b/ui/app/css/itcss/components/loading-overlay.scss index b07d6af17..b023c8423 100644 --- a/ui/app/css/itcss/components/loading-overlay.scss +++ b/ui/app/css/itcss/components/loading-overlay.scss @@ -1,6 +1,6 @@ .loading-overlay { left: 0; - z-index: 50; + z-index: 51; position: absolute; flex-direction: column; display: flex; @@ -8,25 +8,9 @@ align-items: center; flex: 1 1 auto; width: 100%; + height: 100%; background: rgba(255, 255, 255, .8); - @media screen and (max-width: 575px) { - margin-top: 66px; - height: calc(100% - 66px); - } - - @media screen and (min-width: 576px) { - margin-top: 75px; - height: calc(100% - 75px); - } - - &--full-screen { - position: fixed; - height: 100vh; - width: 100vw; - margin-top: 0; - } - &__container { position: absolute; top: 33%; diff --git a/ui/app/css/itcss/components/network.scss b/ui/app/css/itcss/components/network.scss index b23876d01..833a91f12 100644 --- a/ui/app/css/itcss/components/network.scss +++ b/ui/app/css/itcss/components/network.scss @@ -59,6 +59,15 @@ font-weight: 500; } +.dropdown-menu-item .fa.delete { + margin-right: 10px; + display: none; +} + +.dropdown-menu-item:hover .fa.delete { + display: inherit; +} + .network-droppo { right: 2px; diff --git a/ui/app/css/itcss/components/newui-sections.scss b/ui/app/css/itcss/components/newui-sections.scss index bbfd85c90..8e963d495 100644 --- a/ui/app/css/itcss/components/newui-sections.scss +++ b/ui/app/css/itcss/components/newui-sections.scss @@ -6,7 +6,6 @@ $sub-mid-size-breakpoint-range: "screen and (min-width: #{$break-large}) and (ma */ // Component Colors -$tx-view-bg: $white; $wallet-view-bg: $alabaster; // Main container @@ -23,6 +22,12 @@ $wallet-view-bg: $alabaster; display: none; } +.main-container-wrapper { + display: flex; + width: 100vw; + justify-content: center; +} + //Account and transaction details .account-and-transaction-details { display: flex; @@ -30,32 +35,6 @@ $wallet-view-bg: $alabaster; min-width: 0; } -// tx view - -.tx-view { - flex: 1 1 66.5%; - background: $tx-view-bg; - min-width: 0; - - // No title on mobile - @media screen and (max-width: 575px) { - .identicon-wrapper { - display: none; - } - - .account-name { - display: none; - } - } -} - -.open-in-browser { - cursor: pointer; - display: flex; - justify-content: center; - padding: 10px; -} - // wallet view and sidebar .wallet-view { @@ -175,7 +154,7 @@ $wallet-view-bg: $alabaster; } } -.wallet-view.sidebar { +.wallet-view.sidebar-right { flex: 1 0 230px; background: rgb(250, 250, 250); z-index: $sidebar-z-index; @@ -193,20 +172,6 @@ $wallet-view-bg: $alabaster; height: calc(100% - 56px); } -.sidebar-overlay { - z-index: $sidebar-overlay-z-index; - position: fixed; - // top: 41px; - height: 100%; - width: 100%; - left: 0; - right: 0; - bottom: 0; - opacity: 1; - visibility: visible; - background-color: rgba(0, 0, 0, .3); -} - // main-container media queries @media screen and (min-width: 576px) { @@ -260,6 +225,10 @@ $wallet-view-bg: $alabaster; overflow-y: auto; background-color: $white; } + + .main-container-wrapper { + height: 100%; + } } // wallet view diff --git a/ui/app/css/itcss/components/request-signature.scss b/ui/app/css/itcss/components/request-signature.scss index b607aded3..445b9ebf5 100644 --- a/ui/app/css/itcss/components/request-signature.scss +++ b/ui/app/css/itcss/components/request-signature.scss @@ -23,6 +23,25 @@ } } + &__typed-container { + padding: 17px; + + h1 { + font-weight: 900; + margin-bottom: 5px; + } + + * { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > div { + margin-bottom: 10px; + } + } + &__header { height: 64px; width: 100%; diff --git a/ui/app/css/itcss/components/send.scss b/ui/app/css/itcss/components/send.scss index 806ac8536..a57653b45 100644 --- a/ui/app/css/itcss/components/send.scss +++ b/ui/app/css/itcss/components/send.scss @@ -622,12 +622,14 @@ position: relative; &__down-caret { + z-index: 1026; position: absolute; top: 18px; right: 12px; } &__qr-code { + z-index: 1026; position: absolute; top: 13px; right: 33px; @@ -647,6 +649,8 @@ &__to-autocomplete, &__memo-text-area, &__hex-data { &__input { + z-index: 1025; + position: relative; height: 54px; width: 100%; border: 1px solid $alto; @@ -827,11 +831,17 @@ &__error-message { display: block; position: absolute; - top: 4px; - right: 4px; + top: -18px; + right: 0; font-size: 12px; line-height: 12px; color: $red; + width: 100%; + text-align: center; + } + + &__cancel { + margin-right: 10px; } } @@ -880,12 +890,21 @@ font-size: 18px; color: $tundora; right: 0px; - padding: 1px 4px; + padding: 0; display: flex; justify-content: space-around; align-items: center; } + .gas-tooltip-input-arrow-wrapper { + align-items: center; + align-self: stretch; + display: flex; + flex-direction: column; + flex-grow: 1; + justify-content: center; + } + input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; display: none; diff --git a/ui/app/css/itcss/components/settings.scss b/ui/app/css/itcss/components/settings.scss deleted file mode 100644 index 0dd61ac5e..000000000 --- a/ui/app/css/itcss/components/settings.scss +++ /dev/null @@ -1,214 +0,0 @@ -.settings { - position: relative; - background: $white; - display: flex; - flex-flow: column nowrap; -} - -.settings__header { - padding: 25px; -} - -.settings__close-button::after { - content: '\00D7'; - font-size: 40px; - color: $dusty-gray; - position: absolute; - top: 25px; - right: 30px; - cursor: pointer; -} - -.settings__error { - padding-bottom: 20px; - text-align: center; - color: $crimson; -} - -.settings__content { - padding: 0 25px; - height: auto; - overflow: auto; -} - -.settings__content-row { - display: flex; - flex-direction: row; - padding: 10px 0 20px; - - @media screen and (max-width: 575px) { - flex-direction: column; - padding: 10px 0; - } -} - -.settings__content-item { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - padding: 0 5px; - height: 71px; - - @media screen and (max-width: 575px) { - height: initial; - padding: 5px 0; - } - - &--without-height { - height: initial; - } -} - -.settings__content-item-col { - max-width: 300px; - display: flex; - flex-direction: column; - - @media screen and (max-width: 575px) { - max-width: 100%; - width: 100%; - } -} - -.settings__content-description { - font-size: 14px; - color: $dusty-gray; - padding-top: 5px; -} - -.settings__input { - padding-left: 10px; - font-size: 14px; - height: 40px; - border: 1px solid $alto; -} - -.settings__input::-webkit-input-placeholder { - font-weight: 100; - color: $dusty-gray; -} - -.settings__input::-moz-placeholder { - font-weight: 100; - color: $dusty-gray; -} - -.settings__input:-ms-input-placeholder { - font-weight: 100; - color: $dusty-gray; -} - -.settings__input:-moz-placeholder { - font-weight: 100; - color: $dusty-gray; -} - -.settings__provider-wrapper { - font-size: 16px; - border: 1px solid $alto; - border-radius: 2px; - padding: 15px; - background-color: $white; - display: flex; - align-items: center; - justify-content: flex-start; -} - -.settings__provider-icon { - height: 10px; - width: 10px; - margin-right: 10px; - border-radius: 10px; -} - -.settings__rpc-save-button { - align-self: flex-end; - padding: 5px; - text-transform: uppercase; - color: $dusty-gray; - cursor: pointer; -} - -.settings__button--red { - border-color: lighten($monzo, 10%); - color: $monzo; - - &:active { - background: lighten($monzo, 55%); - border-color: $monzo; - } - - &:hover { - border-color: $monzo; - } -} - -.settings__button--orange { - border-color: lighten($ecstasy, 20%); - color: $ecstasy; - - &:active { - background: lighten($ecstasy, 40%); - border-color: $ecstasy; - } - - &:hover { - border-color: $ecstasy; - } -} - -.settings__info-logo-wrapper { - height: 80px; - margin-bottom: 20px; -} - -.settings__info-logo { - max-height: 100%; - max-width: 100%; -} - -.settings__info-item { - padding: 10px 0; -} - -.settings__info-link-header { - padding-bottom: 15px; - - @media screen and (max-width: 575px) { - padding-bottom: 5px; - } -} - -.settings__info-link-item { - padding: 15px 0; - - @media screen and (max-width: 575px) { - padding: 5px 0; - } -} - -.settings__info-version-number { - padding-top: 5px; - font-size: 13px; - color: $dusty-gray; -} - -.settings__info-about { - color: $dusty-gray; - margin-bottom: 15px; -} - -.settings__info-link { - color: $curious-blue; -} - -.settings__info-separator { - margin: 15px 0; - width: 80px; - border-color: $alto; - border: none; - height: 1px; - background-color: $alto; - color: $alto; -} diff --git a/ui/app/css/itcss/components/transaction-list.scss b/ui/app/css/itcss/components/transaction-list.scss index 1d45ff13b..3435353d9 100644 --- a/ui/app/css/itcss/components/transaction-list.scss +++ b/ui/app/css/itcss/components/transaction-list.scss @@ -243,7 +243,7 @@ } .tx-list-item { - border-top: 1px solid rgb(231, 231, 231); + border-bottom: 1px solid $geyser; flex: 0 0 auto; display: flex; flex-flow: row nowrap; diff --git a/ui/app/ducks/confirm-transaction.duck.js b/ui/app/ducks/confirm-transaction.duck.js index 1885e12d1..30c32f2bf 100644 --- a/ui/app/ducks/confirm-transaction.duck.js +++ b/ui/app/ducks/confirm-transaction.duck.js @@ -5,9 +5,7 @@ import { } from '../selectors/confirm-transaction' import { - getTokenData, - getMethodData, - getTransactionAmount, + getValueFromWeiHex, getTransactionFee, getHexGasTotal, addFiat, @@ -16,6 +14,7 @@ import { hexGreaterThan, } from '../helpers/confirm-transaction/util' +import { getTokenData, getMethodData, isSmartContractAddress } from '../helpers/transactions.util' import { getSymbolAndDecimals } from '../token-util' import { conversionUtil } from '../conversion-util' @@ -35,8 +34,9 @@ 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') +const UPDATE_TO_SMART_CONTRACT = createActionType('UPDATE_TO_SMART_CONTRACT') +const FETCH_DATA_START = createActionType('FETCH_DATA_START') +const FETCH_DATA_END = createActionType('FETCH_DATA_END') // Initial state const initState = { @@ -55,7 +55,8 @@ const initState = { ethTransactionTotal: '', hexGasTotal: '', nonce: '', - fetchingMethodData: false, + toSmartContract: false, + fetchingData: false, } // Reducer @@ -138,15 +139,20 @@ export default function reducer ({ confirmTransaction: confirmState = initState ...confirmState, nonce: action.payload, } - case FETCH_METHOD_DATA_START: + case UPDATE_TO_SMART_CONTRACT: return { ...confirmState, - fetchingMethodData: true, + toSmartContract: action.payload, } - case FETCH_METHOD_DATA_END: + case FETCH_DATA_START: return { ...confirmState, - fetchingMethodData: false, + fetchingData: true, + } + case FETCH_DATA_END: + return { + ...confirmState, + fetchingData: false, } case CLEAR_CONFIRM_TRANSACTION: return initState @@ -237,9 +243,16 @@ export function updateNonce (nonce) { } } -export function setFetchingMethodData (isFetching) { +export function updateToSmartContract (toSmartContract) { + return { + type: UPDATE_TO_SMART_CONTRACT, + payload: toSmartContract, + } +} + +export function setFetchingData (isFetching) { return { - type: isFetching ? FETCH_METHOD_DATA_START : FETCH_METHOD_DATA_END, + type: isFetching ? FETCH_DATA_START : FETCH_DATA_END, } } @@ -286,10 +299,10 @@ export function updateTxDataAndCalculate (txData) { const { txParams: { value, gas: gasLimit = '0x0', gasPrice = '0x0' } = {} } = txData - const fiatTransactionAmount = getTransactionAmount({ + const fiatTransactionAmount = getValueFromWeiHex({ value, toCurrency: currentCurrency, conversionRate, numberOfDecimals: 2, }) - const ethTransactionAmount = getTransactionAmount({ + const ethTransactionAmount = getValueFromWeiHex({ value, toCurrency: 'ETH', conversionRate, numberOfDecimals: 6, }) @@ -338,19 +351,22 @@ export function setTransactionToConfirm (transactionId) { dispatch(updateTxDataAndCalculate(txData)) const { txParams } = transaction + const { to } = txParams if (txParams.data) { const { tokens: existingTokens } = state const { data, to: tokenAddress } = txParams try { - dispatch(setFetchingMethodData(true)) + dispatch(setFetchingData(true)) const methodData = await getMethodData(data) dispatch(updateMethodData(methodData)) - dispatch(setFetchingMethodData(false)) + const toSmartContract = await isSmartContractAddress(to) + dispatch(updateToSmartContract(toSmartContract)) + dispatch(setFetchingData(false)) } catch (error) { dispatch(updateMethodData({})) - dispatch(setFetchingMethodData(false)) + dispatch(setFetchingData(false)) } const tokenData = getTokenData(data) diff --git a/ui/app/ducks/tests/confirm-transaction.duck.test.js b/ui/app/ducks/tests/confirm-transaction.duck.test.js index 111674e33..1bab0add0 100644 --- a/ui/app/ducks/tests/confirm-transaction.duck.test.js +++ b/ui/app/ducks/tests/confirm-transaction.duck.test.js @@ -1,6 +1,7 @@ import assert from 'assert' import configureMockStore from 'redux-mock-store' import thunk from 'redux-thunk' +import sinon from 'sinon' import ConfirmTransactionReducer, * as actions from '../confirm-transaction.duck.js' @@ -20,7 +21,8 @@ const initialState = { ethTransactionTotal: '', hexGasTotal: '', nonce: '', - fetchingMethodData: false, + toSmartContract: false, + fetchingData: false, } const UPDATE_TX_DATA = 'metamask/confirm-transaction/UPDATE_TX_DATA' @@ -35,8 +37,9 @@ const UPDATE_TRANSACTION_TOTALS = 'metamask/confirm-transaction/UPDATE_TRANSACTI 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 UPDATE_TO_SMART_CONTRACT = 'metamask/confirm-transaction/UPDATE_TO_SMART_CONTRACT' +const FETCH_DATA_START = 'metamask/confirm-transaction/FETCH_DATA_START' +const FETCH_DATA_END = 'metamask/confirm-transaction/FETCH_DATA_END' const CLEAR_CONFIRM_TRANSACTION = 'metamask/confirm-transaction/CLEAR_CONFIRM_TRANSACTION' describe('Confirm Transaction Duck', () => { @@ -64,7 +67,8 @@ describe('Confirm Transaction Duck', () => { ethTransactionTotal: '469.27', hexGasTotal: '0x1319718a5000', nonce: '0x0', - fetchingMethodData: false, + toSmartContract: false, + fetchingData: false, }, } @@ -271,30 +275,43 @@ describe('Confirm Transaction Duck', () => { ) }) - it('should set fetchingMethodData to true when receiving a FETCH_METHOD_DATA_START action', () => { + it('should update nonce when receiving an UPDATE_TO_SMART_CONTRACT action', () => { assert.deepEqual( ConfirmTransactionReducer(mockState, { - type: FETCH_METHOD_DATA_START, + type: UPDATE_TO_SMART_CONTRACT, + payload: true, }), { ...mockState.confirmTransaction, - fetchingMethodData: true, + toSmartContract: true, } ) }) - it('should set fetchingMethodData to false when receiving a FETCH_METHOD_DATA_END action', () => { + it('should set fetchingData to true when receiving a FETCH_DATA_START action', () => { assert.deepEqual( - ConfirmTransactionReducer({ confirmTransaction: { fetchingMethodData: true } }, { - type: FETCH_METHOD_DATA_END, + ConfirmTransactionReducer(mockState, { + type: FETCH_DATA_START, }), { - fetchingMethodData: false, + ...mockState.confirmTransaction, + fetchingData: true, } ) }) - it('should clear confirmTransaction when receiving a FETCH_METHOD_DATA_END action', () => { + it('should set fetchingData to false when receiving a FETCH_DATA_END action', () => { + assert.deepEqual( + ConfirmTransactionReducer({ confirmTransaction: { fetchingData: true } }, { + type: FETCH_DATA_END, + }), + { + fetchingData: false, + } + ) + }) + + it('should clear confirmTransaction when receiving a FETCH_DATA_END action', () => { assert.deepEqual( ConfirmTransactionReducer(mockState, { type: CLEAR_CONFIRM_TRANSACTION, @@ -460,24 +477,24 @@ describe('Confirm Transaction Duck', () => { ) }) - it('should create an action to set fetchingMethodData to true', () => { + it('should create an action to set fetchingData to true', () => { const expectedAction = { - type: FETCH_METHOD_DATA_START, + type: FETCH_DATA_START, } assert.deepEqual( - actions.setFetchingMethodData(true), + actions.setFetchingData(true), expectedAction ) }) - it('should create an action to set fetchingMethodData to false', () => { + it('should create an action to set fetchingData to false', () => { const expectedAction = { - type: FETCH_METHOD_DATA_END, + type: FETCH_DATA_END, } assert.deepEqual( - actions.setFetchingMethodData(false), + actions.setFetchingData(false), expectedAction ) }) @@ -495,6 +512,18 @@ describe('Confirm Transaction Duck', () => { }) describe('Thunk actions', done => { + beforeEach(() => { + global.eth = { + getCode: sinon.stub().callsFake( + address => Promise.resolve(address && address.match(/isContract/) ? 'not-0x' : '0x') + ), + } + }) + + afterEach(() => { + global.eth.getCode.resetHistory() + }) + it('updates txData and gas on an existing transaction in confirmTransaction', () => { const mockState = { metamask: { @@ -505,7 +534,7 @@ describe('Confirm Transaction Duck', () => { ethTransactionAmount: '1', ethTransactionFee: '0.000021', ethTransactionTotal: '1.000021', - fetchingMethodData: false, + fetchingData: false, fiatTransactionAmount: '469.26', fiatTransactionFee: '0.01', fiatTransactionTotal: '469.27', @@ -581,7 +610,7 @@ describe('Confirm Transaction Duck', () => { ethTransactionAmount: '1', ethTransactionFee: '0.000021', ethTransactionTotal: '1.000021', - fetchingMethodData: false, + fetchingData: false, fiatTransactionAmount: '469.26', fiatTransactionFee: '0.01', fiatTransactionTotal: '469.27', @@ -667,6 +696,7 @@ describe('Confirm Transaction Duck', () => { .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/common.util.js b/ui/app/helpers/common.util.js new file mode 100644 index 000000000..0c02481e6 --- /dev/null +++ b/ui/app/helpers/common.util.js @@ -0,0 +1,5 @@ +export function camelCaseToCapitalize (str = '') { + return str + .replace(/([A-Z])/g, ' $1') + .replace(/^./, str => str.toUpperCase()) +} diff --git a/ui/app/helpers/confirm-transaction/util.js b/ui/app/helpers/confirm-transaction/util.js index 76e80a8ac..bcac22500 100644 --- a/ui/app/helpers/confirm-transaction/util.js +++ b/ui/app/helpers/confirm-transaction/util.js @@ -1,15 +1,8 @@ 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' import BigNumber from 'bignumber.js' -abiDecoder.addABI(abi) - -import MethodRegistry from 'eth-method-registry' -const registry = new MethodRegistry({ provider: global.ethereumProvider }) - import { conversionUtil, addCurrencies, @@ -19,22 +12,6 @@ import { import { unconfirmedTransactionsCountSelector } from '../../selectors/confirm-transaction' -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, @@ -76,11 +53,12 @@ export function addFiat (...args) { }) } -export function getTransactionAmount ({ +export function getValueFromWeiHex ({ value, toCurrency, conversionRate, numberOfDecimals, + toDenomination, }) { return conversionUtil(value, { fromNumericBase: 'hex', @@ -89,6 +67,7 @@ export function getTransactionAmount ({ toCurrency, numberOfDecimals, fromDenomination: 'WEI', + toDenomination, conversionRate, }) } diff --git a/ui/app/helpers/confirm-transaction/util.test.js b/ui/app/helpers/confirm-transaction/util.test.js index a9c8fae34..4c1a3e16b 100644 --- a/ui/app/helpers/confirm-transaction/util.test.js +++ b/ui/app/helpers/confirm-transaction/util.test.js @@ -92,9 +92,9 @@ describe('Confirm Transaction utils', () => { }) }) - describe('getTransactionAmount', () => { + describe('getValueFromWeiHex', () => { it('should get the transaction amount in ETH', () => { - const ethTransactionAmount = utils.getTransactionAmount({ + const ethTransactionAmount = utils.getValueFromWeiHex({ value: '0xde0b6b3a7640000', toCurrency: 'ETH', conversionRate: 468.58, numberOfDecimals: 6, }) @@ -102,7 +102,7 @@ describe('Confirm Transaction utils', () => { }) it('should get the transaction amount in fiat', () => { - const fiatTransactionAmount = utils.getTransactionAmount({ + const fiatTransactionAmount = utils.getValueFromWeiHex({ value: '0xde0b6b3a7640000', toCurrency: 'usd', conversionRate: 468.58, numberOfDecimals: 2, }) diff --git a/ui/app/helpers/conversions.util.js b/ui/app/helpers/conversions.util.js new file mode 100644 index 000000000..20ef9e35b --- /dev/null +++ b/ui/app/helpers/conversions.util.js @@ -0,0 +1,63 @@ +import ethUtil from 'ethereumjs-util' +import { conversionUtil } from '../conversion-util' +import { ETH, GWEI, WEI } from '../constants/common' + +export function bnToHex (inputBn) { + return ethUtil.addHexPrefix(inputBn.toString(16)) +} + +export function hexToDecimal (hexValue) { + return conversionUtil(hexValue, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + }) +} + +export function decimalToHex (decimal) { + return conversionUtil(decimal, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }) +} + +export function getEthConversionFromWeiHex ({ value, conversionRate, numberOfDecimals = 6 }) { + const denominations = [ETH, GWEI, WEI] + + let nonZeroDenomination + + for (let i = 0; i < denominations.length; i++) { + const convertedValue = getValueFromWeiHex({ + value, + conversionRate, + toCurrency: ETH, + numberOfDecimals, + toDenomination: denominations[i], + }) + + if (convertedValue !== '0' || i === denominations.length - 1) { + nonZeroDenomination = `${convertedValue} ${denominations[i]}` + break + } + } + + return nonZeroDenomination +} + +export function getValueFromWeiHex ({ + value, + toCurrency, + conversionRate, + numberOfDecimals, + toDenomination, +}) { + return conversionUtil(value, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromCurrency: ETH, + toCurrency, + numberOfDecimals, + fromDenomination: WEI, + toDenomination, + conversionRate, + }) +} diff --git a/ui/app/helpers/tests/common.util.test.js b/ui/app/helpers/tests/common.util.test.js new file mode 100644 index 000000000..a52b91a10 --- /dev/null +++ b/ui/app/helpers/tests/common.util.test.js @@ -0,0 +1,27 @@ +import * as utils from '../common.util' +import assert from 'assert' + +describe('Common utils', () => { + describe('camelCaseToCapitalize', () => { + it('should return a capitalized string from a camel-cased string', () => { + const tests = [ + { + test: undefined, + expected: '', + }, + { + test: '', + expected: '', + }, + { + test: 'thisIsATest', + expected: 'This Is A Test', + }, + ] + + tests.forEach(({ test, expected }) => { + assert.equal(utils.camelCaseToCapitalize(test), expected) + }) + }) + }) +}) diff --git a/ui/app/helpers/tests/transactions.util.test.js b/ui/app/helpers/tests/transactions.util.test.js new file mode 100644 index 000000000..103a84a8c --- /dev/null +++ b/ui/app/helpers/tests/transactions.util.test.js @@ -0,0 +1,22 @@ +import * as utils from '../transactions.util' +import assert from 'assert' + +describe('Transactions utils', () => { + describe('getTokenData', () => { + it('should return token data', () => { + const tokenData = utils.getTokenData('0xa9059cbb00000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a7060000000000000000000000000000000000000000000000000000000000004e20') + assert.ok(tokenData) + const { name, params } = tokenData + assert.equal(name, 'transfer') + const [to, value] = params + assert.equal(to.name, '_to') + assert.equal(to.type, 'address') + assert.equal(value.name, '_value') + assert.equal(value.type, 'uint256') + }) + + it('should not throw errors when called without arguments', () => { + assert.doesNotThrow(() => utils.getTokenData()) + }) + }) +}) diff --git a/ui/app/helpers/transactions.util.js b/ui/app/helpers/transactions.util.js new file mode 100644 index 000000000..f7d249e63 --- /dev/null +++ b/ui/app/helpers/transactions.util.js @@ -0,0 +1,128 @@ +import ethUtil from 'ethereumjs-util' +import MethodRegistry from 'eth-method-registry' +import abi from 'human-standard-token-abi' +import abiDecoder from 'abi-decoder' + +import { + TOKEN_METHOD_TRANSFER, + TOKEN_METHOD_APPROVE, + TOKEN_METHOD_TRANSFER_FROM, + SEND_ETHER_ACTION_KEY, + DEPLOY_CONTRACT_ACTION_KEY, + APPROVE_ACTION_KEY, + SEND_TOKEN_ACTION_KEY, + TRANSFER_FROM_ACTION_KEY, + SIGNATURE_REQUEST_KEY, + UNKNOWN_FUNCTION_KEY, + CANCEL_ATTEMPT_ACTION_KEY, +} from '../constants/transactions' + +import { addCurrencies } from '../conversion-util' + +abiDecoder.addABI(abi) + +export function getTokenData (data = '') { + return abiDecoder.decodeMethod(data) +} + +const registry = new MethodRegistry({ provider: global.ethereumProvider }) + +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 isConfirmDeployContract (txData = {}) { + const { txParams = {} } = txData + return !txParams.to +} + +/** + * Returns the action of a transaction as a key to be passed into the translator. + * @param {Object} transaction - txData object + * @param {Object} methodData - Data returned from eth-method-registry + * @returns {string|undefined} + */ +export async function getTransactionActionKey (transaction, methodData) { + const { txParams: { data, to } = {}, msgParams, type } = transaction + + if (type === 'cancel') { + return CANCEL_ATTEMPT_ACTION_KEY + } + + if (msgParams) { + return SIGNATURE_REQUEST_KEY + } + + if (isConfirmDeployContract(transaction)) { + return DEPLOY_CONTRACT_ACTION_KEY + } + + if (data) { + const toSmartContract = await isSmartContractAddress(to) + + if (!toSmartContract) { + return SEND_ETHER_ACTION_KEY + } + + const { name } = methodData + const methodName = name && name.toLowerCase() + + if (!methodName) { + return UNKNOWN_FUNCTION_KEY + } + + switch (methodName) { + case TOKEN_METHOD_TRANSFER: + return SEND_TOKEN_ACTION_KEY + case TOKEN_METHOD_APPROVE: + return APPROVE_ACTION_KEY + case TOKEN_METHOD_TRANSFER_FROM: + return TRANSFER_FROM_ACTION_KEY + default: + return undefined + } + } else { + return SEND_ETHER_ACTION_KEY + } +} + +export function getLatestSubmittedTxWithNonce (transactions = [], nonce = '0x0') { + if (!transactions.length) { + return {} + } + + return transactions.reduce((acc, current) => { + const { submittedTime, txParams: { nonce: currentNonce } = {} } = current + + if (currentNonce === nonce) { + return acc.submittedTime + ? submittedTime > acc.submittedTime ? current : acc + : current + } else { + return acc + } + }, {}) +} + +export async function isSmartContractAddress (address) { + const code = await global.eth.getCode(address) + return code && code !== '0x' +} + +export function sumHexes (...args) { + const total = args.reduce((acc, base) => { + return addCurrencies(acc, base, { + toNumericBase: 'hex', + }) + }) + + return ethUtil.addHexPrefix(total) +} diff --git a/ui/app/higher-order-components/with-method-data/index.js b/ui/app/higher-order-components/with-method-data/index.js new file mode 100644 index 000000000..f511e1ae7 --- /dev/null +++ b/ui/app/higher-order-components/with-method-data/index.js @@ -0,0 +1 @@ +export { default } from './with-method-data.component' diff --git a/ui/app/higher-order-components/with-method-data/with-method-data.component.js b/ui/app/higher-order-components/with-method-data/with-method-data.component.js new file mode 100644 index 000000000..fed7d9865 --- /dev/null +++ b/ui/app/higher-order-components/with-method-data/with-method-data.component.js @@ -0,0 +1,52 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { getMethodData } from '../../helpers/transactions.util' + +export default function withMethodData (WrappedComponent) { + return class MethodDataWrappedComponent extends PureComponent { + static propTypes = { + transaction: PropTypes.object, + } + + static defaultProps = { + transaction: {}, + } + + state = { + methodData: {}, + done: false, + error: null, + } + + componentDidMount () { + this.fetchMethodData() + } + + async fetchMethodData () { + const { transaction } = this.props + const { txParams: { data = '' } = {} } = transaction + + if (data) { + try { + const methodData = await getMethodData(data) + this.setState({ methodData, done: true }) + } catch (error) { + this.setState({ done: true, error }) + } + } else { + this.setState({ done: true }) + } + } + + render () { + const { methodData, done, error } = this.state + + return ( + <WrappedComponent + { ...this.props } + methodData={{ data: methodData, done, error }} + /> + ) + } + } +} diff --git a/ui/app/higher-order-components/with-modal-props/index.js b/ui/app/higher-order-components/with-modal-props/index.js new file mode 100644 index 000000000..e476b51d2 --- /dev/null +++ b/ui/app/higher-order-components/with-modal-props/index.js @@ -0,0 +1 @@ +export { default } from './with-modal-props' diff --git a/ui/app/higher-order-components/with-modal-props/tests/with-modal-props.test.js b/ui/app/higher-order-components/with-modal-props/tests/with-modal-props.test.js new file mode 100644 index 000000000..654e7062a --- /dev/null +++ b/ui/app/higher-order-components/with-modal-props/tests/with-modal-props.test.js @@ -0,0 +1,43 @@ + +import assert from 'assert' +import configureMockStore from 'redux-mock-store' +import { mount } from 'enzyme' +import React from 'react' +import withModalProps from '../with-modal-props' + +const mockState = { + appState: { + modal: { + modalState: { + props: { + prop1: 'prop1', + prop2: 2, + prop3: true, + }, + }, + }, + }, +} + +describe('withModalProps', () => { + it('should return a component wrapped with modal state props', () => { + const TestComponent = props => ( + <div className="test">Testing</div> + ) + const WrappedComponent = withModalProps(TestComponent) + const store = configureMockStore()(mockState) + const wrapper = mount( + <WrappedComponent store={store} /> + ) + + assert.ok(wrapper) + const testComponent = wrapper.find(TestComponent).at(0) + assert.equal(testComponent.length, 1) + assert.equal(testComponent.find('.test').text(), 'Testing') + const testComponentProps = testComponent.props() + assert.equal(testComponentProps.prop1, 'prop1') + assert.equal(testComponentProps.prop2, 2) + assert.equal(testComponentProps.prop3, true) + assert.equal(typeof testComponentProps.hideModal, 'function') + }) +}) diff --git a/ui/app/higher-order-components/with-modal-props/with-modal-props.js b/ui/app/higher-order-components/with-modal-props/with-modal-props.js new file mode 100644 index 000000000..02f3855af --- /dev/null +++ b/ui/app/higher-order-components/with-modal-props/with-modal-props.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux' +import { hideModal } from '../../actions' + +const mapStateToProps = state => { + const { appState } = state + const { props: modalProps } = appState.modal.modalState + + return { + ...modalProps, + } +} + +const mapDispatchToProps = dispatch => { + return { + hideModal: () => dispatch(hideModal()), + } +} + +export default function withModalProps (Component) { + return connect(mapStateToProps, mapDispatchToProps)(Component) +} diff --git a/ui/app/higher-order-components/with-token-tracker/index.js b/ui/app/higher-order-components/with-token-tracker/index.js new file mode 100644 index 000000000..d401e81f1 --- /dev/null +++ b/ui/app/higher-order-components/with-token-tracker/index.js @@ -0,0 +1 @@ +export { default } from './with-token-tracker.component' diff --git a/ui/app/helpers/with-token-tracker.js b/ui/app/higher-order-components/with-token-tracker/with-token-tracker.component.js index 8608b15f4..36f6a6efd 100644 --- a/ui/app/helpers/with-token-tracker.js +++ b/ui/app/higher-order-components/with-token-tracker/with-token-tracker.component.js @@ -2,7 +2,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import TokenTracker from 'eth-token-tracker' -const withTokenTracker = WrappedComponent => { +export default function withTokenTracker (WrappedComponent) { return class TokenTrackerWrappedComponent extends Component { static propTypes = { userAddress: PropTypes.string.isRequired, @@ -104,5 +104,3 @@ const withTokenTracker = WrappedComponent => { } } } - -module.exports = withTokenTracker diff --git a/ui/app/i18n-provider.js b/ui/app/i18n-provider.js index d46911f7c..3419474c4 100644 --- a/ui/app/i18n-provider.js +++ b/ui/app/i18n-provider.js @@ -6,6 +6,11 @@ const { compose } = require('recompose') const t = require('../i18n-helper').getMessage class I18nProvider extends Component { + tOrDefault = (key, defaultValue, ...args) => { + const { localeMessages: { current, en } = {} } = this.props + return t(current, key, ...args) || t(en, key, ...args) || defaultValue + } + getChildContext () { const { localeMessages } = this.props const { current, en } = localeMessages @@ -13,6 +18,10 @@ class I18nProvider extends Component { t (key, ...args) { return t(current, key, ...args) || t(en, key, ...args) || `[${key}]` }, + tOrDefault: this.tOrDefault, + tOrKey (key, ...args) { + return this.tOrDefault(key, key, ...args) + }, } } @@ -28,6 +37,8 @@ I18nProvider.propTypes = { I18nProvider.childContextTypes = { t: PropTypes.func, + tOrDefault: PropTypes.func, + tOrKey: PropTypes.func, } const mapStateToProps = state => { diff --git a/ui/app/main-container.js b/ui/app/main-container.js deleted file mode 100644 index 8a0708025..000000000 --- a/ui/app/main-container.js +++ /dev/null @@ -1,49 +0,0 @@ -const Component = require('react').Component -const h = require('react-hyperscript') -const inherits = require('util').inherits -const AccountAndTransactionDetails = require('./account-and-transaction-details') -const Settings = require('./components/pages/settings') -const log = require('loglevel') - -import UnlockScreen from './components/pages/unlock-page' - -module.exports = MainContainer - -inherits(MainContainer, Component) -function MainContainer () { - Component.call(this) -} - -MainContainer.prototype.render = function () { - // 3. summarize: - // switch statement goes inside MainContainer, - // or a method in renderPrimary - // - pass resulting h() to MainContainer - // - error checking in separate func - // - router in separate func - const contents = { - component: AccountAndTransactionDetails, - key: 'account-detail', - style: {}, - } - - if (this.props.isUnlocked === false) { - switch (this.props.currentViewName) { - case 'config': - log.debug('rendering config screen from unlock screen.') - return h(Settings, {key: 'config'}) - default: - log.debug('rendering locked screen') - return h('.unlock-screen-container', {}, h(UnlockScreen, { key: 'locked' })) - } - } - - return h('div.main-container', { - style: contents.style, - }, [ - h(contents.component, { - key: contents.key, - }, []), - ]) -} - diff --git a/ui/app/new-keychain.js b/ui/app/new-keychain.js deleted file mode 100644 index cc9633166..000000000 --- a/ui/app/new-keychain.js +++ /dev/null @@ -1,29 +0,0 @@ -const inherits = require('util').inherits -const Component = require('react').Component -const h = require('react-hyperscript') -const connect = require('react-redux').connect - -module.exports = connect(mapStateToProps)(NewKeychain) - -function mapStateToProps (state) { - return {} -} - -inherits(NewKeychain, Component) -function NewKeychain () { - Component.call(this) -} - -NewKeychain.prototype.render = function () { - // const props = this.props - - return ( - h('div', { - style: { - background: 'blue', - }, - }, [ - h('h1', `Here's a list!!!!`), - ]) - ) -} diff --git a/ui/app/reducers/app.js b/ui/app/reducers/app.js index c246e7904..5c86d397d 100644 --- a/ui/app/reducers/app.js +++ b/ui/app/reducers/app.js @@ -48,7 +48,11 @@ function reduceApp (state, action) { name: null, }, }, - sidebarOpen: false, + sidebar: { + isOpen: false, + transitionName: '', + type: '', + }, alertOpen: false, alertMessage: null, qrCodeData: null, @@ -88,12 +92,18 @@ function reduceApp (state, action) { // sidebar methods case actions.SIDEBAR_OPEN: return extend(appState, { - sidebarOpen: true, + sidebar: { + ...action.value, + isOpen: true, + }, }) case actions.SIDEBAR_CLOSE: return extend(appState, { - sidebarOpen: false, + sidebar: { + ...appState.sidebar, + isOpen: false, + }, }) // alert methods @@ -209,6 +219,15 @@ function reduceApp (state, action) { transForward: action.value, }) + case actions.SHOW_ADD_SUGGESTED_TOKEN_PAGE: + return extend(appState, { + currentView: { + name: 'add-suggested-token', + context: appState.currentView.context, + }, + transForward: action.value, + }) + case actions.SHOW_IMPORT_PAGE: return extend(appState, { currentView: { diff --git a/ui/app/routes.js b/ui/app/routes.js index f6b2a7a55..76afed5db 100644 --- a/ui/app/routes.js +++ b/ui/app/routes.js @@ -7,6 +7,7 @@ const CONFIRM_SEED_ROUTE = '/confirm-seed' const RESTORE_VAULT_ROUTE = '/restore-vault' const ADD_TOKEN_ROUTE = '/add-token' const CONFIRM_ADD_TOKEN_ROUTE = '/confirm-add-token' +const CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE = '/confirm-add-suggested-token' const NEW_ACCOUNT_ROUTE = '/new-account' const IMPORT_ACCOUNT_ROUTE = '/new-account/import' const CONNECT_HARDWARE_ROUTE = '/new-account/connect' @@ -41,6 +42,7 @@ module.exports = { RESTORE_VAULT_ROUTE, ADD_TOKEN_ROUTE, CONFIRM_ADD_TOKEN_ROUTE, + CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE, NEW_ACCOUNT_ROUTE, IMPORT_ACCOUNT_ROUTE, CONNECT_HARDWARE_ROUTE, diff --git a/ui/app/selectors.js b/ui/app/selectors.js index d86462275..fb4517628 100644 --- a/ui/app/selectors.js +++ b/ui/app/selectors.js @@ -1,6 +1,9 @@ -const valuesFor = require('./util').valuesFor const abi = require('human-standard-token-abi') +import { + transactionsSelector, +} from './selectors/transactions' + const { multiplyCurrencies, } = require('./conversion-util') @@ -11,6 +14,8 @@ const selectors = { getSelectedAccount, getSelectedToken, getSelectedTokenExchangeRate, + getSelectedTokenAssetImage, + getAssetImages, getTokenExchangeRate, conversionRateSelector, transactionsSelector, @@ -68,6 +73,18 @@ function getSelectedTokenExchangeRate (state) { return contractExchangeRates[address] || 0 } +function getSelectedTokenAssetImage (state) { + const assetImages = state.metamask.assetImages || {} + const selectedToken = getSelectedToken(state) || {} + const { address } = selectedToken + return assetImages[address] +} + +function getAssetImages (state) { + const assetImages = state.metamask.assetImages || {} + return assetImages +} + function getTokenExchangeRate (state, address) { const contractExchangeRates = state.metamask.contractExchangeRates return contractExchangeRates[address] || 0 @@ -101,22 +118,6 @@ function getCurrentAccountWithSendEtherInfo (state) { return accounts.find(({ address }) => address === currentAddress) } -function transactionsSelector (state) { - const { network, selectedTokenAddress } = state.metamask - const unapprovedMsgs = valuesFor(state.metamask.unapprovedMsgs) - const shapeShiftTxList = (network === '1') ? state.metamask.shapeShiftTxList : undefined - const transactions = state.metamask.selectedAddressTxList || [] - const txsToRender = !shapeShiftTxList ? transactions.concat(unapprovedMsgs) : transactions.concat(unapprovedMsgs, shapeShiftTxList) - - // console.log({txsToRender, selectedTokenAddress}) - return selectedTokenAddress - ? txsToRender - .filter(({ txParams }) => txParams && txParams.to === selectedTokenAddress) - .sort((a, b) => b.time - a.time) - : txsToRender - .sort((a, b) => b.time - a.time) -} - function getGasIsLoading (state) { return state.appState.gasIsLoading } diff --git a/ui/app/selectors/confirm-transaction.js b/ui/app/selectors/confirm-transaction.js index 6e760c429..86b10bac3 100644 --- a/ui/app/selectors/confirm-transaction.js +++ b/ui/app/selectors/confirm-transaction.js @@ -126,7 +126,8 @@ const TOKEN_PARAM_VALUE = '_value' export const tokenAmountAndToAddressSelector = createSelector( tokenDataParamsSelector, - params => { + tokenDecimalsSelector, + (params, tokenDecimals) => { let toAddress = '' let tokenAmount = 0 @@ -136,6 +137,10 @@ export const tokenAmountAndToAddressSelector = createSelector( toAddress = toParam ? toParam.value : params[0].value const value = valueParam ? Number(valueParam.value) : Number(params[1].value) tokenAmount = roundExponential(value) + + if (tokenDecimals) { + tokenAmount = calcTokenAmount(value, tokenDecimals) + } } return { diff --git a/ui/app/selectors/tokens.js b/ui/app/selectors/tokens.js new file mode 100644 index 000000000..47b6e0192 --- /dev/null +++ b/ui/app/selectors/tokens.js @@ -0,0 +1,11 @@ +import { createSelector } from 'reselect' + +export const selectedTokenAddressSelector = state => state.metamask.selectedTokenAddress +export const tokenSelector = state => state.metamask.tokens +export const selectedTokenSelector = createSelector( + tokenSelector, + selectedTokenAddressSelector, + (tokens = [], selectedTokenAddress = '') => { + return tokens.find(({ address }) => address === selectedTokenAddress) + } +) diff --git a/ui/app/selectors/transactions.js b/ui/app/selectors/transactions.js new file mode 100644 index 000000000..479002794 --- /dev/null +++ b/ui/app/selectors/transactions.js @@ -0,0 +1,58 @@ +import { createSelector } from 'reselect' +import { valuesFor } from '../util' +import { + UNAPPROVED_STATUS, + APPROVED_STATUS, + SUBMITTED_STATUS, +} from '../constants/transactions' + +import { selectedTokenAddressSelector } from './tokens' + +export const shapeShiftTxListSelector = state => state.metamask.shapeShiftTxList +export const unapprovedMsgsSelector = state => state.metamask.unapprovedMsgs +export const selectedAddressTxListSelector = state => state.metamask.selectedAddressTxList + +const pendingStatusHash = { + [UNAPPROVED_STATUS]: true, + [APPROVED_STATUS]: true, + [SUBMITTED_STATUS]: true, +} + +export const transactionsSelector = createSelector( + selectedTokenAddressSelector, + unapprovedMsgsSelector, + shapeShiftTxListSelector, + selectedAddressTxListSelector, + (selectedTokenAddress, unapprovedMsgs = {}, shapeShiftTxList = [], transactions = []) => { + const unapprovedMsgsList = valuesFor(unapprovedMsgs) + const txsToRender = transactions.concat(unapprovedMsgsList, shapeShiftTxList) + + return selectedTokenAddress + ? txsToRender + .filter(({ txParams }) => txParams && txParams.to === selectedTokenAddress) + .sort((a, b) => b.time - a.time) + : txsToRender + .sort((a, b) => b.time - a.time) + } +) + +export const pendingTransactionsSelector = createSelector( + transactionsSelector, + (transactions = []) => ( + transactions.filter(transaction => transaction.status in pendingStatusHash).reverse() + ) +) + +export const submittedPendingTransactionsSelector = createSelector( + transactionsSelector, + (transactions = []) => ( + transactions.filter(transaction => transaction.status === SUBMITTED_STATUS) + ) +) + +export const completedTransactionsSelector = createSelector( + transactionsSelector, + (transactions = []) => ( + transactions.filter(transaction => !(transaction.status in pendingStatusHash)) + ) +) diff --git a/ui/app/token-util.js b/ui/app/token-util.js index 8798ed266..6e4992763 100644 --- a/ui/app/token-util.js +++ b/ui/app/token-util.js @@ -1,55 +1,118 @@ const log = require('loglevel') const util = require('./util') const BigNumber = require('bignumber.js') +import contractMap from 'eth-contract-metadata' -function tokenInfoGetter () { - const tokens = {} +const casedContractMap = Object.keys(contractMap).reduce((acc, base) => { + return { + ...acc, + [base.toLowerCase()]: contractMap[base], + } +}, {}) - return async (address) => { - if (tokens[address]) { - return tokens[address] +const DEFAULT_SYMBOL = '' +const DEFAULT_DECIMALS = '0' + +async function getSymbolFromContract (tokenAddress) { + const token = util.getContractAtAddress(tokenAddress) + + try { + const result = await token.symbol() + return result[0] + } catch (error) { + log.warn(`symbol() call for token at address ${tokenAddress} resulted in error:`, error) + } +} + +async function getDecimalsFromContract (tokenAddress) { + const token = util.getContractAtAddress(tokenAddress) + + try { + const result = await token.decimals() + const decimalsBN = result[0] + return decimalsBN && decimalsBN.toString() + } catch (error) { + log.warn(`decimals() call for token at address ${tokenAddress} resulted in error:`, error) + } +} + +function getContractMetadata (tokenAddress) { + return tokenAddress && casedContractMap[tokenAddress.toLowerCase()] +} + +async function getSymbol (tokenAddress) { + let symbol = await getSymbolFromContract(tokenAddress) + + if (!symbol) { + const contractMetadataInfo = getContractMetadata(tokenAddress) + + if (contractMetadataInfo) { + symbol = contractMetadataInfo.symbol } + } - tokens[address] = await getSymbolAndDecimals(address) + return symbol +} - return tokens[address] +async function getDecimals (tokenAddress) { + let decimals = await getDecimalsFromContract(tokenAddress) + + if (!decimals || decimals === '0') { + const contractMetadataInfo = getContractMetadata(tokenAddress) + + if (contractMetadataInfo) { + decimals = contractMetadataInfo.decimals + } } + + return decimals } -async function getSymbolAndDecimals (tokenAddress, existingTokens = []) { +export async function getSymbolAndDecimals (tokenAddress, existingTokens = []) { const existingToken = existingTokens.find(({ address }) => tokenAddress === address) + if (existingToken) { - return existingToken + return { + symbol: existingToken.symbol, + decimals: existingToken.decimals, + } } - let result = [] + let symbol, decimals + try { - const token = util.getContractAtAddress(tokenAddress) + symbol = await getSymbol(tokenAddress) + decimals = await getDecimals(tokenAddress) + } catch (error) { + log.warn(`symbol() and decimal() calls for token at address ${tokenAddress} resulted in error:`, error) + } - result = await Promise.all([ - token.symbol(), - token.decimals(), - ]) - } catch (err) { - log.warn(`symbol() and decimal() calls for token at address ${tokenAddress} resulted in error:`, err) + return { + symbol: symbol || DEFAULT_SYMBOL, + decimals: decimals || DEFAULT_DECIMALS, } +} - const [ symbol = [], decimals = [] ] = result +export function tokenInfoGetter () { + const tokens = {} - return { - symbol: symbol[0] || null, - decimals: decimals[0] && decimals[0].toString() || null, + return async (address) => { + if (tokens[address]) { + return tokens[address] + } + + tokens[address] = await getSymbolAndDecimals(address) + + return tokens[address] } } -function calcTokenAmount (value, decimals) { +export function calcTokenAmount (value, decimals) { const multiplier = Math.pow(10, Number(decimals || 0)) return new BigNumber(String(value)).div(multiplier).toNumber() } - -module.exports = { - tokenInfoGetter, - calcTokenAmount, - getSymbolAndDecimals, +export function getTokenValue (tokenParams = []) { + const valueData = tokenParams.find(param => param.name === '_value') + return valueData && valueData.value } diff --git a/ui/app/util.js b/ui/app/util.js index ade4fec8a..37c0fb698 100644 --- a/ui/app/util.js +++ b/ui/app/util.js @@ -9,7 +9,7 @@ const MIN_GAS_PRICE_BN = MIN_GAS_PRICE_GWEI_BN.mul(GWEI_FACTOR) // formatData :: ( date: <Unix Timestamp> ) -> String function formatDate (date) { - return vreme.format(new Date(date), 'March 16 2014 14:30') + return vreme.format(new Date(date), '3/16/2014 at 14:30') } var valueTable = { diff --git a/ui/i18n-helper.js b/ui/i18n-helper.js index bc927ee65..c6a7d0bf1 100644 --- a/ui/i18n-helper.js +++ b/ui/i18n-helper.js @@ -20,10 +20,10 @@ const getMessage = (locale, key, substitutions) => { let phrase = entry.message // perform substitutions if (substitutions && substitutions.length) { - phrase = phrase.replace(/\$1/g, substitutions[0]) - if (substitutions.length > 1) { - phrase = phrase.replace(/\$2/g, substitutions[1]) - } + substitutions.forEach((substitution, index) => { + const regex = new RegExp(`\\$${index + 1}`, 'g') + phrase = phrase.replace(regex, substitution) + }) } return phrase } |