From 3ec2f534632426876c28b22c58cbbf14b4904d97 Mon Sep 17 00:00:00 2001 From: Chi Kei Chan Date: Thu, 21 Sep 2017 18:44:52 -0700 Subject: [PATCH] Integrate Add Token --- ui/app/actions.js | 39 +- ui/app/add-token.js | 536 ++++++++++----------- ui/app/components/token-balance.js | 14 +- ui/app/components/tx-list.js | 2 +- ui/app/css/itcss/components/add-token.scss | 95 +++- ui/app/css/itcss/components/buttons.scss | 10 +- 6 files changed, 387 insertions(+), 309 deletions(-) diff --git a/ui/app/actions.js b/ui/app/actions.js index 678c68a6a..1231fc296 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -147,6 +147,7 @@ var actions = { SHOW_ADD_TOKEN_PAGE: 'SHOW_ADD_TOKEN_PAGE', showAddTokenPage, addToken, + addTokens, setRpcTarget: setRpcTarget, setDefaultRpcTarget: setDefaultRpcTarget, setProviderType: setProviderType, @@ -700,18 +701,40 @@ function showAddTokenPage () { function addToken (address, symbol, decimals) { return (dispatch) => { dispatch(actions.showLoadingIndication()) - background.addToken(address, symbol, decimals, (err) => { - dispatch(actions.hideLoadingIndication()) - if (err) { - return dispatch(actions.displayWarning(err.message)) - } - setTimeout(() => { - dispatch(actions.goHome()) - }, 250) + return new Promise((resolve, reject) => { + background.addToken(address, symbol, decimals, (err) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + dispatch(actions.displayWarning(err.message)) + reject(err) + } + resolve() + // setTimeout(() => { + // dispatch(actions.goHome()) + // }, 250) + }) }) } } +function addTokens (tokens) { + return dispatch => { + if (Array.isArray(tokens)) { + return Promise.all(tokens.map(({ address, symbol, decimals }) => ( + dispatch(addToken(address, symbol, decimals)) + ))) + } else { + return Promise.all( + Object + .entries(tokens) + .map(([_, { address, symbol, decimals }]) => ( + dispatch(addToken(address, symbol, decimals)) + )) + ) + } + } +} + function goBackToInitView () { return { type: actions.BACK_TO_INIT_MENU, diff --git a/ui/app/add-token.js b/ui/app/add-token.js index 622cf2bc2..f723ff07c 100644 --- a/ui/app/add-token.js +++ b/ui/app/add-token.js @@ -5,6 +5,8 @@ const h = require('react-hyperscript') const connect = require('react-redux').connect const Fuse = require('fuse.js') const contractMap = require('eth-contract-metadata') +const TokenBalance = require('./components/token-balance') +const Identicon = require('./components/identicon') const contractList = Object.entries(contractMap).map(([ _, tokenData]) => tokenData) const fuse = new Fuse(contractList, { shouldSort: true, @@ -16,9 +18,6 @@ const fuse = new Fuse(contractList, { keys: ['address', 'name', 'symbol'], }) const actions = require('./actions') -// const Tooltip = require('./components/tooltip.js') - - const ethUtil = require('ethereumjs-util') const abi = require('human-standard-token-abi') const Eth = require('ethjs-query') @@ -37,275 +36,27 @@ function mapStateToProps (state) { function mapDispatchToProps (dispatch) { return { goHome: () => dispatch(actions.goHome()), + addTokens: tokens => dispatch(actions.addTokens(tokens)), } } inherits(AddTokenScreen, Component) function AddTokenScreen () { this.state = { - // warning: null, - // address: null, - // symbol: 'TOKEN', - // decimals: 18, + isShowingConfirmation: false, customAddress: '', customSymbol: '', customDecimals: 0, searchQuery: '', isCollapsed: true, - selectedToken: {}, + selectedTokens: {}, + errors: {}, } this.tokenAddressDidChange = this.tokenAddressDidChange.bind(this) + this.onNext = this.onNext.bind(this) Component.call(this) } -AddTokenScreen.prototype.toggleToken = function (symbol) { - const { selectedToken } = this.state - const { [symbol]: isSelected } = selectedToken - this.setState({ - selectedToken: { - ...selectedToken, - [symbol]: !isSelected, - }, - }) -} - -AddTokenScreen.prototype.renderCustomForm = function () { - const { customAddress, customSymbol, customDecimals } = this.state - - return !this.state.isCollapsed && ( - h('div.add-token__add-custom-form', [ - h('div.add-token__add-custom-field', [ - h('div.add-token__add-custom-label', 'Token Address'), - h('input.add-token__add-custom-input', { - type: 'text', - onChange: this.tokenAddressDidChange, - value: customAddress, - }), - ]), - h('div.add-token__add-custom-field', [ - h('div.add-token__add-custom-label', 'Token Symbol'), - h('input.add-token__add-custom-input', { - type: 'text', - value: customSymbol, - disabled: true, - }), - ]), - h('div.add-token__add-custom-field', [ - h('div.add-token__add-custom-label', 'Decimals of Precision'), - h('input.add-token__add-custom-input', { - type: 'number', - value: customDecimals, - disabled: true, - }), - ]), - ]) - ) -} - -AddTokenScreen.prototype.renderTokenList = function () { - const { searchQuery = '', selectedToken } = this.state - const results = searchQuery - ? fuse.search(searchQuery) || [] - : contractList - - return Array(6).fill(undefined) - .map((_, i) => { - const { logo, symbol, name } = results[i] || {} - return Boolean(logo || symbol || name) && ( - h('div.add-token__token-wrapper', { - className: classnames('add-token__token-wrapper', { - 'add-token__token-wrapper--selected': selectedToken[symbol], - }), - onClick: () => this.toggleToken(symbol), - }, [ - h('div.add-token__token-icon', { - style: { - backgroundImage: `url(images/contract/${logo})`, - }, - }), - h('div.add-token__token-data', [ - h('div.add-token__token-symbol', symbol), - h('div.add-token__token-name', name), - ]), - ]) - ) - }) -} - -AddTokenScreen.prototype.render = function () { - const { isCollapsed } = this.state - const { goHome } = this.props - - return ( - h('div.add-token', [ - h('div.add-token__wrapper', [ - h('div.add-token__title-container', [ - h('div.add-token__title', 'Add Token'), - h('div.add-token__description', 'Keep track of the tokens you’ve bought with your MetaMask account. If you bought tokens using a different account, those tokens will not appear here.'), - h('div.add-token__description', 'Search for tokens or select from our list of popular tokens.'), - ]), - h('div.add-token__content-container', [ - h('div.add-token__input-container', [ - h('input.add-token__input', { - type: 'text', - placeholder: 'Search', - onChange: e => this.setState({ searchQuery: e.target.value }), - }), - ]), - h( - 'div.add-token__token-icons-container', - this.renderTokenList(), - ), - ]), - h('div.add-token__footers', [ - h('div.add-token__add-custom', { - onClick: () => this.setState({ isCollapsed: !isCollapsed }), - }, 'Add custom token'), - this.renderCustomForm(), - ]), - ]), - h('div.add-token__buttons', [ - h('button.btn-secondary', 'Next'), - h('button.btn-tertiary', { - onClick: goHome, - }, 'Cancel'), - ]), - ]) - ) -} - -// AddTokenScreen.prototype.render = function () { -// const state = this.state -// const props = this.props -// const { warning, symbol, decimals } = state - -// return ( -// h('.flex-column.flex-grow', [ - -// // subtitle and nav -// h('.section-title.flex-row.flex-center', [ -// h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { -// onClick: (event) => { -// props.dispatch(actions.goHome()) -// }, -// }), -// h('h2.page-subtitle', 'Add Token'), -// ]), - -// h('.error', { -// style: { -// display: warning ? 'block' : 'none', -// padding: '0 20px', -// textAlign: 'center', -// }, -// }, warning), - -// // conf view -// h('.flex-column.flex-justify-center.flex-grow.select-none', [ -// h('.flex-space-around', { -// style: { -// padding: '20px', -// }, -// }, [ - -// h('div', [ -// h(Tooltip, { -// position: 'top', -// title: 'The contract of the actual token contract. Click for more info.', -// }, [ -// h('a', { -// style: { fontWeight: 'bold', paddingRight: '10px'}, -// href: 'https://consensyssupport.happyfox.com/staff/kb/article/24-what-is-a-token-contract-address', -// target: '_blank', -// }, [ -// h('span', 'Token Contract Address '), -// h('i.fa.fa-question-circle'), -// ]), -// ]), -// ]), - -// h('section.flex-row.flex-center', [ -// h('input#token-address', { -// name: 'address', -// placeholder: 'Token Contract Address', -// onChange: this.tokenAddressDidChange.bind(this), -// style: { -// width: 'inherit', -// flex: '1 0 auto', -// height: '30px', -// margin: '8px', -// }, -// }), -// ]), - -// h('div', [ -// h('span', { -// style: { fontWeight: 'bold', paddingRight: '10px'}, -// }, 'Token Symbol'), -// ]), - -// h('div', { style: {display: 'flex'} }, [ -// h('input#token_symbol', { -// placeholder: `Like "ETH"`, -// value: symbol, -// style: { -// width: 'inherit', -// flex: '1 0 auto', -// height: '30px', -// margin: '8px', -// }, -// onChange: (event) => { -// var element = event.target -// var symbol = element.value -// this.setState({ symbol }) -// }, -// }), -// ]), - -// h('div', [ -// h('span', { -// style: { fontWeight: 'bold', paddingRight: '10px'}, -// }, 'Decimals of Precision'), -// ]), - -// h('div', { style: {display: 'flex'} }, [ -// h('input#token_decimals', { -// value: decimals, -// type: 'number', -// min: 0, -// max: 36, -// style: { -// width: 'inherit', -// flex: '1 0 auto', -// height: '30px', -// margin: '8px', -// }, -// onChange: (event) => { -// var element = event.target -// var decimals = element.value.trim() -// this.setState({ decimals }) -// }, -// }), -// ]), - -// h('button', { -// style: { -// alignSelf: 'center', -// }, -// onClick: (event) => { -// const valid = this.validateInputs() -// if (!valid) return - -// const { address, symbol, decimals } = this.state -// this.props.dispatch(actions.addToken(address.trim(), symbol.trim(), decimals)) -// }, -// }, 'Add'), -// ]), -// ]), -// ]) -// ) -// } - AddTokenScreen.prototype.componentWillMount = function () { if (typeof global.ethereumProvider === 'undefined') return @@ -314,6 +65,29 @@ AddTokenScreen.prototype.componentWillMount = function () { this.TokenContract = this.contract(abi) } +AddTokenScreen.prototype.toggleToken = function (address, token) { + const { selectedTokens, errors } = this.state + const { [address]: selectedToken } = selectedTokens + this.setState({ + selectedTokens: { + ...selectedTokens, + [address]: selectedToken ? null : token, + }, + errors: { + ...errors, + tokenSelector: null, + }, + }) +} + +AddTokenScreen.prototype.onNext = function () { + const { isValid, errors } = this.validate() + + return !isValid + ? this.setState({ errors }) + : this.setState({ isShowingConfirmation: true }) +} + AddTokenScreen.prototype.tokenAddressDidChange = function (e) { const customAddress = e.target.value.trim() this.setState({ customAddress }) @@ -327,45 +101,46 @@ AddTokenScreen.prototype.tokenAddressDidChange = function (e) { } } -AddTokenScreen.prototype.validateInputs = function () { - let msg = '' - const state = this.state +AddTokenScreen.prototype.validate = function () { + const errors = {} const identitiesList = Object.keys(this.props.identities) - const { address, symbol, decimals } = state - const standardAddress = ethUtil.addHexPrefix(address).toLowerCase() + const { customAddress, customSymbol, customDecimals, selectedTokens } = this.state + const standardAddress = ethUtil.addHexPrefix(customAddress).toLowerCase() - const validAddress = ethUtil.isValidAddress(address) - if (!validAddress) { - msg += 'Address is invalid. ' + if (customAddress) { + const validAddress = ethUtil.isValidAddress(customAddress) + if (!validAddress) { + errors.customAddress = 'Address is invalid. ' + } + + const validDecimals = customDecimals >= 0 && customDecimals < 36 + if (!validDecimals) { + errors.customDecimals = 'Decimals must be at least 0, and not over 36.' + } + + const symbolLen = customSymbol.trim().length + const validSymbol = symbolLen > 0 && symbolLen < 10 + if (!validSymbol) { + errors.customSymbol = 'Symbol must be between 0 and 10 characters.' + } + + const ownAddress = identitiesList.includes(standardAddress) + if (ownAddress) { + errors.customAddress = 'Personal address detected. Input the token contract address.' + } + } else if ( + Object.entries(selectedTokens) + .reduce((isEmpty, [ symbol, isSelected ]) => ( + isEmpty && !isSelected + ), true) + ) { + errors.tokenSelector = 'Must select at least 1 token.' } - const validDecimals = decimals >= 0 && decimals < 36 - if (!validDecimals) { - msg += 'Decimals must be at least 0, and not over 36. ' + return { + isValid: !Object.keys(errors).length, + errors, } - - const symbolLen = symbol.trim().length - const validSymbol = symbolLen > 0 && symbolLen < 10 - if (!validSymbol) { - msg += 'Symbol must be between 0 and 10 characters.' - } - - const ownAddress = identitiesList.includes(standardAddress) - if (ownAddress) { - msg = 'Personal address detected. Input the token contract address.' - } - - const isValid = validAddress && validDecimals && !ownAddress - - if (!isValid) { - this.setState({ - warning: msg, - }) - } else { - this.setState({ warning: null }) - } - - return isValid } AddTokenScreen.prototype.attemptToAutoFillTokenParams = async function (address) { @@ -384,3 +159,184 @@ AddTokenScreen.prototype.attemptToAutoFillTokenParams = async function (address) }) } } + +AddTokenScreen.prototype.renderCustomForm = function () { + const { customAddress, customSymbol, customDecimals, errors } = this.state + + return !this.state.isCollapsed && ( + h('div.add-token__add-custom-form', [ + h('div', { + className: classnames('add-token__add-custom-field', { + 'add-token__add-custom-field--error': errors.customAddress, + }), + }, [ + h('div.add-token__add-custom-label', 'Token Address'), + h('input.add-token__add-custom-input', { + type: 'text', + onChange: this.tokenAddressDidChange, + value: customAddress, + }), + h('div.add-token__add-custom-error-message', errors.customAddress), + ]), + h('div', { + className: classnames('add-token__add-custom-field', { + 'add-token__add-custom-field--error': errors.customSymbol, + }), + }, [ + h('div.add-token__add-custom-label', 'Token Symbol'), + h('input.add-token__add-custom-input', { + type: 'text', + value: customSymbol, + disabled: true, + }), + h('div.add-token__add-custom-error-message', errors.customSymbol), + ]), + h('div', { + className: classnames('add-token__add-custom-field', { + 'add-token__add-custom-field--error': errors.customDecimals, + }), + }, [ + h('div.add-token__add-custom-label', 'Decimals of Precision'), + h('input.add-token__add-custom-input', { + type: 'number', + value: customDecimals, + disabled: true, + }), + h('div.add-token__add-custom-error-message', errors.customDecimals), + ]), + ]) + ) +} + +AddTokenScreen.prototype.renderTokenList = function () { + const { searchQuery = '', selectedTokens } = this.state + const results = searchQuery + ? fuse.search(searchQuery) || [] + : contractList + + return Array(6).fill(undefined) + .map((_, i) => { + const { logo, symbol, name, address } = results[i] || {} + return Boolean(logo || symbol || name) && ( + h('div.add-token__token-wrapper', { + className: classnames('add-token__token-wrapper', { + 'add-token__token-wrapper--selected': selectedTokens[address], + }), + onClick: () => this.toggleToken(address, results[i]), + }, [ + h('div.add-token__token-icon', { + style: { + backgroundImage: `url(images/contract/${logo})`, + }, + }), + h('div.add-token__token-data', [ + h('div.add-token__token-symbol', symbol), + h('div.add-token__token-name', name), + ]), + ]) + ) + }) +} + +AddTokenScreen.prototype.renderConfirmation = function () { + const { + customAddress: address, + customSymbol: symbol, + customDecimals: decimals, + selectedTokens, + } = this.state + + const { addTokens, goHome } = this.props + + const customToken = { + address, + symbol, + decimals, + } + + const tokens = address && symbol && decimals + ? { ...selectedTokens, [address]: customToken } + : selectedTokens + + return ( + h('div.add-token', [ + h('div.add-token__wrapper', [ + h('div.add-token__title-container.add-token__confirmation-title', [ + h('div.add-token__title', 'Add Token'), + h('div.add-token__description', 'Would you like to add these tokens?'), + ]), + h('div.add-token__content-container.add-token__confirmation-content', [ + h('div.add-token__description.add-token__confirmation-description', 'Your balances'), + h('div.add-token__confirmation-token-list', + Object.entries(tokens) + .map(([ address, token ]) => ( + h('span.add-token__confirmation-token-list-item', [ + h(Identicon, { + className: 'add-token__confirmation-token-icon', + diameter: 75, + address, + }), + h(TokenBalance, { token }), + ]) + )) + ), + ]), + ]), + h('div.add-token__buttons', [ + h('button.btn-secondary', { + onClick: () => addTokens(tokens).then(goHome), + }, 'Add Tokens'), + h('button.btn-tertiary', { + onClick: () => this.setState({ isShowingConfirmation: false }), + }, 'Back'), + ]), + ]) + ) +} + +AddTokenScreen.prototype.render = function () { + const { isCollapsed, errors, isShowingConfirmation } = this.state + const { goHome } = this.props + + return isShowingConfirmation + ? this.renderConfirmation() + : ( + h('div.add-token', [ + h('div.add-token__wrapper', [ + h('div.add-token__title-container', [ + h('div.add-token__title', 'Add Token'), + h('div.add-token__description', 'Keep track of the tokens you’ve bought with your MetaMask account. If you bought tokens using a different account, those tokens will not appear here.'), + h('div.add-token__description', 'Search for tokens or select from our list of popular tokens.'), + ]), + h('div.add-token__content-container', [ + h('div.add-token__input-container', [ + h('input.add-token__input', { + type: 'text', + placeholder: 'Search', + onChange: e => this.setState({ searchQuery: e.target.value }), + }), + h('div.add-token__search-input-error-message', errors.tokenSelector), + ]), + h( + 'div.add-token__token-icons-container', + this.renderTokenList(), + ), + ]), + h('div.add-token__footers', [ + h('div.add-token__add-custom', { + onClick: () => this.setState({ isCollapsed: !isCollapsed }), + }, 'Add custom token'), + this.renderCustomForm(), + ]), + ]), + h('div.add-token__buttons', [ + h('button.btn-secondary', { + onClick: this.onNext, + }, 'Next'), + h('button.btn-tertiary', { + onClick: goHome, + }, 'Cancel'), + ]), + ]) + ) +} diff --git a/ui/app/components/token-balance.js b/ui/app/components/token-balance.js index 3a923eb9d..0342c1da9 100644 --- a/ui/app/components/token-balance.js +++ b/ui/app/components/token-balance.js @@ -17,7 +17,8 @@ module.exports = connect(mapStateToProps)(TokenBalance) inherits(TokenBalance, Component) function TokenBalance () { this.state = { - balance: '', + string: '', + symbol: '', isLoading: true, error: null, } @@ -26,11 +27,14 @@ function TokenBalance () { TokenBalance.prototype.render = function () { const state = this.state - const { balance, isLoading } = state + const { symbol, string, balanceOnly, isLoading } = state return isLoading ? h('span', '') - : h('span', balance) + : h('span.token-balance', [ + h('span.token-balance__amount', string), + !balanceOnly && h('span.token-balance__symbol', symbol), + ]) } TokenBalance.prototype.componentDidMount = function () { @@ -93,10 +97,10 @@ TokenBalance.prototype.componentDidUpdate = function (nextProps) { TokenBalance.prototype.updateBalance = function (tokens = []) { const [{ string, symbol }] = tokens - const { balanceOnly } = this.props this.setState({ - balance: balanceOnly ? string : `${string} ${symbol}`, + string, + symbol, isLoading: false, }) } diff --git a/ui/app/components/tx-list.js b/ui/app/components/tx-list.js index 7a147e942..f817d03a9 100644 --- a/ui/app/components/tx-list.js +++ b/ui/app/components/tx-list.js @@ -77,7 +77,7 @@ TxList.prototype.renderTransactionListItem = function (transaction, conversionRa const { showConfTxPage } = this.props const opts = { - key: transActionId, + key: transActionId || transactionHash, txParams: transaction.txParams, transactionStatus, transActionId, diff --git a/ui/app/css/itcss/components/add-token.scss b/ui/app/css/itcss/components/add-token.scss index ebfdf7b11..d5d1aab71 100644 --- a/ui/app/css/itcss/components/add-token.scss +++ b/ui/app/css/itcss/components/add-token.scss @@ -56,6 +56,10 @@ margin-top: 24px; } + &__confirmation-description { + margin: 12px 0; + } + &__content-container { width: 100%; border-bottom: 1px solid $gallery; @@ -65,6 +69,18 @@ padding: 11px 0; width: 263px; margin: 0 auto; + position: relative; + } + + &__search-input-error-message { + position: absolute; + bottom: -10px; + font-size: 12px; + width: 100%; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + color: $red; } &__input { @@ -89,9 +105,13 @@ font-size: 18px; line-height: 24px; text-align: center; - padding: 11px 0 19px; + padding: 12px 0; font-weight: 600; cursor: pointer; + + &:hover { + background-color: $gallery; + } } &__add-custom-form { @@ -103,6 +123,24 @@ &__add-custom-field { width: 290px; margin: 0 auto; + position: relative; + + &--error { + .add-token__add-custom-input { + border-color: $red; + } + } + } + + &__add-custom-error-message { + position: absolute; + bottom: -21px; + font-size: 12px; + width: 100%; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + color: $red; } &__add-custom-label { @@ -152,9 +190,12 @@ cursor: pointer; border: 2px solid transparent; - &:hover, + &:hover { + border: 2px solid rgba($malibu-blue, .5); + } + &--selected { - border: 2px solid $malibu-blue; + border: 2px solid $malibu-blue !important; } } @@ -181,4 +222,52 @@ margin-right: 12px; flex: 0 0 auto; } + + &__confirmation-token-list { + display: flex; + flex-flow: column nowrap; + + .token-balance { + display: flex; + flex-flow: row nowrap; + align-items: flex-start; + + &__amount { + color: $scorpion; + font-size: 43px; + font-weight: 300; + line-height: 43px; + margin-right: 8px; + } + + &__symbol { + color: $scorpion; + font-size: 16px; + line-height: 24px; + } + } + } + + &__confirmation-title { + padding: 30px 120px 12px; + } + + &__confirmation-content { + padding-bottom: 60px; + } + + &__confirmation-token-list-item { + display: flex; + flex-flow: row nowrap; + padding: 0 120px; + align-items: center; + } + + &__confirmation-token-list-item + &__confirmation-token-list-item { + margin-top: 30px; + } + + &__confirmation-token-icon { + margin-right: 18px; + } } diff --git a/ui/app/css/itcss/components/buttons.scss b/ui/app/css/itcss/components/buttons.scss index 0946cdbbb..2c5e6cf57 100644 --- a/ui/app/css/itcss/components/buttons.scss +++ b/ui/app/css/itcss/components/buttons.scss @@ -30,8 +30,9 @@ button.btn-clear { button[disabled], input[type="submit"][disabled] { cursor: not-allowed; - background: rgba(197, 197, 197, 1); - box-shadow: 0 3px 6px rgba(197, 197, 197, .36); + opacity: .5; + // background: rgba(197, 197, 197, 1); + // box-shadow: 0 3px 6px rgba(197, 197, 197, .36); } // button.spaced { @@ -90,6 +91,11 @@ button.btn-thin { font-size: 16px; line-height: 24px; padding: 16px 42px; + + &[disabled] { + background-color: $white !important; + opacity: .5; + } } .btn-tertiary {