From 4f6b53c1aa383c05fa3c356adb332fcc6dbf45e9 Mon Sep 17 00:00:00 2001 From: Alexander Tseung Date: Sat, 19 May 2018 23:04:19 -0700 Subject: [PATCH 1/3] Update designs for Add Token screen --- app/_locales/en/messages.json | 11 +- app/images/search.svg | 14 + app/images/tokensearch.svg | 15 + package-lock.json | 161 +++--- package.json | 2 +- ui/app/actions.js | 23 + ui/app/app.js | 3 + ui/app/components/button/button.component.js | 19 +- ui/app/components/button/index.js | 2 +- ...{export-text-container.scss => index.scss} | 0 ui/app/components/index.scss | 5 + ui/app/components/info-box/index.js | 2 + ui/app/components/info-box/index.scss | 24 + .../components/info-box/info-box.component.js | 49 ++ ui/app/components/pages/add-token.js | 431 ---------------- .../pages/add-token/add-token.component.js | 356 ++++++++++++++ .../pages/add-token/add-token.container.js | 22 + ui/app/components/pages/add-token/index.js | 2 + ui/app/components/pages/add-token/index.scss | 25 + .../pages/add-token/token-list/index.js | 2 + .../pages/add-token/token-list/index.scss | 65 +++ .../token-list-placeholder/index.js | 2 + .../token-list-placeholder/index.scss | 19 + .../token-list-placeholder.component.js | 27 + .../token-list/token-list.component.js | 60 +++ .../token-list/token-list.container.js | 11 + .../pages/add-token/token-search/index.js | 2 + .../token-search/token-search.component.js | 85 ++++ ui/app/components/pages/add-token/util.js | 13 + .../confirm-add-token.component.js | 115 +++++ .../confirm-add-token.container.js | 20 + .../pages/confirm-add-token/index.js | 2 + .../pages/confirm-add-token/index.scss | 69 +++ .../confirm-add-token/token-balance/index.js | 2 + .../token-balance/token-balance.component.js | 16 + .../token-balance/token-balance.container.js | 16 + ui/app/components/pages/index.scss | 5 + .../{unlock-page.scss => index.scss} | 0 .../unlock-page/unlock-page.component.js | 3 +- ui/app/components/signature-request.js | 2 +- .../text-field/text-field.component.js | 117 +++-- ui/app/css/itcss/components/add-token.scss | 461 ------------------ ui/app/css/itcss/components/buttons.scss | 5 +- ui/app/css/itcss/components/index.scss | 4 +- .../css/itcss/components/newui-sections.scss | 2 +- ui/app/css/itcss/components/pages/index.scss | 2 - ui/app/css/itcss/generic/index.scss | 45 +- ui/app/helpers/with-token-tracker.js | 105 ++++ ui/app/reducers/metamask.js | 12 + ui/app/routes.js | 2 + 50 files changed, 1401 insertions(+), 1056 deletions(-) create mode 100644 app/images/search.svg create mode 100644 app/images/tokensearch.svg rename ui/app/components/export-text-container/{export-text-container.scss => index.scss} (100%) create mode 100644 ui/app/components/index.scss create mode 100644 ui/app/components/info-box/index.js create mode 100644 ui/app/components/info-box/index.scss create mode 100644 ui/app/components/info-box/info-box.component.js delete mode 100644 ui/app/components/pages/add-token.js create mode 100644 ui/app/components/pages/add-token/add-token.component.js create mode 100644 ui/app/components/pages/add-token/add-token.container.js create mode 100644 ui/app/components/pages/add-token/index.js create mode 100644 ui/app/components/pages/add-token/index.scss create mode 100644 ui/app/components/pages/add-token/token-list/index.js create mode 100644 ui/app/components/pages/add-token/token-list/index.scss create mode 100644 ui/app/components/pages/add-token/token-list/token-list-placeholder/index.js create mode 100644 ui/app/components/pages/add-token/token-list/token-list-placeholder/index.scss create mode 100644 ui/app/components/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js create mode 100644 ui/app/components/pages/add-token/token-list/token-list.component.js create mode 100644 ui/app/components/pages/add-token/token-list/token-list.container.js create mode 100644 ui/app/components/pages/add-token/token-search/index.js create mode 100644 ui/app/components/pages/add-token/token-search/token-search.component.js create mode 100644 ui/app/components/pages/add-token/util.js create mode 100644 ui/app/components/pages/confirm-add-token/confirm-add-token.component.js create mode 100644 ui/app/components/pages/confirm-add-token/confirm-add-token.container.js create mode 100644 ui/app/components/pages/confirm-add-token/index.js create mode 100644 ui/app/components/pages/confirm-add-token/index.scss create mode 100644 ui/app/components/pages/confirm-add-token/token-balance/index.js create mode 100644 ui/app/components/pages/confirm-add-token/token-balance/token-balance.component.js create mode 100644 ui/app/components/pages/confirm-add-token/token-balance/token-balance.container.js create mode 100644 ui/app/components/pages/index.scss rename ui/app/components/pages/unlock-page/{unlock-page.scss => index.scss} (100%) delete mode 100644 ui/app/css/itcss/components/add-token.scss create mode 100644 ui/app/helpers/with-token-tracker.js diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 90beda418..fa01fea24 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -23,6 +23,9 @@ "addTokens": { "message": "Add Tokens" }, + "addAcquiredTokens": { + "message": "Add the tokens you've acquired using MetaMask" + }, "amount": { "message": "Amount" }, @@ -53,7 +56,7 @@ "message": "Back" }, "balance": { - "message": "Balance:" + "message": "Balance" }, "balances": { "message": "Token balance(s)" @@ -717,6 +720,9 @@ "search": { "message": "Search" }, + "searchResults": { + "message": "Search Results" + }, "secretPhrase": { "message": "Enter your secret twelve word phrase here to restore your vault." }, @@ -832,6 +838,9 @@ "message": "$1 to ETH via ShapeShift", "description": "system will fill in deposit type in start of message" }, + "token": { + "message": "Token" + }, "tokenAddress": { "message": "Token Address" }, diff --git a/app/images/search.svg b/app/images/search.svg new file mode 100644 index 000000000..44fea12aa --- /dev/null +++ b/app/images/search.svg @@ -0,0 +1,14 @@ + + + + search + Created with Sketch. + + + + + + + + + \ No newline at end of file diff --git a/app/images/tokensearch.svg b/app/images/tokensearch.svg new file mode 100644 index 000000000..cd0b03bf2 --- /dev/null +++ b/app/images/tokensearch.svg @@ -0,0 +1,15 @@ + + + + 7FDB75AD-BD4D-497C-B391-69EEB31A0561 + Created with sketchtool. + + + + + + + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1df46989c..c82990a22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,15 @@ "@babel/types": "7.0.0-beta.31" } }, + "@babel/runtime": { + "version": "7.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.0.0-beta.47.tgz", + "integrity": "sha512-3IaakAC5B4bHJ0aCUKVw0pt+GruavdgWDFbf7TfKh7ZJ8yQuUp7af7MNwf3e+jH8776cjqYmMO1JNDDAE9WfrA==", + "requires": { + "core-js": "2.5.3", + "regenerator-runtime": "0.11.1" + } + }, "@babel/template": { "version": "7.0.0-beta.31", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.0.0-beta.31.tgz", @@ -182,6 +191,74 @@ "through2": "2.0.3" } }, + "@material-ui/core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-1.0.0.tgz", + "integrity": "sha512-BTLp4goHFKGqCVSjSWNSUZp3/fvN36L0B73Z68i4Hs6TRZaApW5M2JyKmWTsCf/hk4PNKTnZMh141qNQFhxzAw==", + "requires": { + "@babel/runtime": "7.0.0-beta.47", + "@types/jss": "9.5.3", + "@types/react-transition-group": "2.0.9", + "brcast": "3.0.1", + "classnames": "2.2.5", + "deepmerge": "2.1.0", + "dom-helpers": "3.3.1", + "hoist-non-react-statics": "2.5.0", + "jss": "9.8.1", + "jss-camel-case": "6.1.0", + "jss-default-unit": "8.0.2", + "jss-global": "3.0.0", + "jss-nested": "6.0.1", + "jss-props-sort": "6.0.0", + "jss-vendor-prefixer": "7.0.0", + "keycode": "2.2.0", + "lodash": "4.17.10", + "normalize-scroll-left": "0.1.2", + "prop-types": "15.6.1", + "react-event-listener": "0.5.3", + "react-jss": "8.4.0", + "react-popper": "0.10.4", + "react-scrollbar-size": "2.1.0", + "react-transition-group": "2.2.1", + "recompose": "0.27.0", + "scroll": "2.0.3", + "warning": "3.0.0" + }, + "dependencies": { + "@types/jss": { + "version": "9.5.3", + "resolved": "https://registry.npmjs.org/@types/jss/-/jss-9.5.3.tgz", + "integrity": "sha512-RQWhcpOVyIhGryKpnUyZARwsgmp+tB82O7c75lC4Tjbmr3hPiCnM1wc+pJipVEOsikYXW0IHgeiQzmxQXbnAIA==", + "requires": { + "csstype": "2.4.2", + "indefinite-observable": "1.0.1" + } + }, + "deepmerge": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.1.0.tgz", + "integrity": "sha512-Q89Z26KAfA3lpPGhbF6XMfYAm3jIV3avViy6KOJ2JLzFbeWHOvPQUu5aSJIWXap3gDZC2y1eF5HXEPI2wGqgvw==" + }, + "hoist-non-react-statics": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.0.tgz", + "integrity": "sha512-6Bl6XsDT1ntE0lHbIhr4Kp2PGcleGZ66qu5Jqk8lc0Xc/IeG6gVLmwUGs/K0Us+L8VWoKgj0uWdPMataOsm31w==" + }, + "recompose": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/recompose/-/recompose-0.27.0.tgz", + "integrity": "sha512-hivr1EopLhzjchhv2Y7VcLA2H5NGztwV/qfYqmIAhTkNowNQ9PyXdfq9Q8QCa0TMrPM1NtStlUyi5I/p8XfUNQ==", + "requires": { + "babel-runtime": "6.26.0", + "change-emitter": "0.1.6", + "fbjs": "0.8.16", + "hoist-non-react-statics": "2.5.0", + "react-lifecycles-compat": "3.0.2", + "symbol-observable": "1.1.0" + } + } + } + }, "@sentry/cli": { "version": "1.30.3", "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-1.30.3.tgz", @@ -1365,15 +1442,6 @@ } } }, - "@types/jss": { - "version": "9.5.2", - "resolved": "https://registry.npmjs.org/@types/jss/-/jss-9.5.2.tgz", - "integrity": "sha512-EX87yNYcisXO5BU9tT7stB7OGuDJyV3JwtMwhfUprrmHwYKWh9a3vchAy6DYzUSbmTA7bD46h8qata5jP1V7Zw==", - "requires": { - "csstype": "2.4.2", - "indefinite-observable": "1.0.1" - } - }, "@types/node": { "version": "8.5.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-8.5.5.tgz", @@ -17752,78 +17820,6 @@ "integrity": "sha1-UpJZPmdUyxvMK5gDDk4Najr8nqE=", "dev": true }, - "material-ui": { - "version": "1.0.0-beta.44", - "resolved": "https://registry.npmjs.org/material-ui/-/material-ui-1.0.0-beta.44.tgz", - "integrity": "sha512-m5SJxvDz77bVKcjyZG/AyG6RBR+UUwkPgvHHLJa2jyAHBNtJMCQ5GVouTXOxaUKlvD5cbO/mcH0YtzugyQTAVg==", - "requires": { - "@types/jss": "9.5.2", - "@types/react-transition-group": "2.0.9", - "babel-runtime": "6.26.0", - "brcast": "3.0.1", - "classnames": "2.2.5", - "deepmerge": "2.1.0", - "dom-helpers": "3.3.1", - "hoist-non-react-statics": "2.5.0", - "jss": "9.8.1", - "jss-camel-case": "6.1.0", - "jss-default-unit": "8.0.2", - "jss-global": "3.0.0", - "jss-nested": "6.0.1", - "jss-props-sort": "6.0.0", - "jss-vendor-prefixer": "7.0.0", - "keycode": "2.2.0", - "lodash": "4.17.10", - "normalize-scroll-left": "0.1.2", - "prop-types": "15.6.1", - "react-event-listener": "0.5.3", - "react-jss": "8.4.0", - "react-lifecycles-compat": "2.0.2", - "react-popper": "0.10.4", - "react-scrollbar-size": "2.1.0", - "react-transition-group": "2.2.1", - "recompose": "0.27.0", - "scroll": "2.0.3", - "warning": "3.0.0" - }, - "dependencies": { - "deepmerge": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.1.0.tgz", - "integrity": "sha512-Q89Z26KAfA3lpPGhbF6XMfYAm3jIV3avViy6KOJ2JLzFbeWHOvPQUu5aSJIWXap3gDZC2y1eF5HXEPI2wGqgvw==" - }, - "hoist-non-react-statics": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.0.tgz", - "integrity": "sha512-6Bl6XsDT1ntE0lHbIhr4Kp2PGcleGZ66qu5Jqk8lc0Xc/IeG6gVLmwUGs/K0Us+L8VWoKgj0uWdPMataOsm31w==" - }, - "react-lifecycles-compat": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-2.0.2.tgz", - "integrity": "sha512-BPksUj7VMAAFhcCw79sZA0Ow/LTAEjs3Sio1AQcuwLeOP+ua0f/08Su2wyiW+JjDDH6fRqNy3h5CLXh21u1mVg==" - }, - "recompose": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/recompose/-/recompose-0.27.0.tgz", - "integrity": "sha512-hivr1EopLhzjchhv2Y7VcLA2H5NGztwV/qfYqmIAhTkNowNQ9PyXdfq9Q8QCa0TMrPM1NtStlUyi5I/p8XfUNQ==", - "requires": { - "babel-runtime": "6.26.0", - "change-emitter": "0.1.6", - "fbjs": "0.8.16", - "hoist-non-react-statics": "2.5.0", - "react-lifecycles-compat": "3.0.3", - "symbol-observable": "1.1.0" - }, - "dependencies": { - "react-lifecycles-compat": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.3.tgz", - "integrity": "sha512-bOr65SSYgxDgDNqLnDqt+gropXGPNB1Wbyys4tOYiNuP/qYWC4qFM9XH1ruzq+tT6EjE29pJsCr19rclKtpUEg==" - } - } - } - } - }, "math-expression-evaluator": { "version": "1.2.17", "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz", @@ -25056,8 +25052,7 @@ "react-lifecycles-compat": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.2.tgz", - "integrity": "sha512-pbZOSMVVkvppW7XRn9fcHK5OgEDnYLwMva7P6TgS44/SN9uGGjfh3Z1c8tomO+y4IsHQ6Fsz2EGwmE7sMeNZgQ==", - "dev": true + "integrity": "sha512-pbZOSMVVkvppW7XRn9fcHK5OgEDnYLwMva7P6TgS44/SN9uGGjfh3Z1c8tomO+y4IsHQ6Fsz2EGwmE7sMeNZgQ==" }, "react-markdown": { "version": "3.1.4", diff --git a/package.json b/package.json index f6338c542..25063b23c 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ ] }, "dependencies": { + "@material-ui/core": "^1.0.0", "abi-decoder": "^1.0.9", "asmcrypto.js": "0.22.0", "async": "^2.5.0", @@ -136,7 +137,6 @@ "lodash.shuffle": "^4.2.0", "lodash.uniqby": "^4.7.0", "loglevel": "^1.4.1", - "material-ui": "1.0.0-beta.44", "metamascara": "^2.0.0", "metamask-logo": "^2.1.4", "mkdirp": "^0.5.1", diff --git a/ui/app/actions.js b/ui/app/actions.js index 2d238b2f8..66a7fdd45 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -275,6 +275,10 @@ var actions = { UPDATE_NETWORK_ENDPOINT_TYPE: 'UPDATE_NETWORK_ENDPOINT_TYPE', retryTransaction, + SET_PENDING_TOKENS: 'SET_PENDING_TOKENS', + CLEAR_PENDING_TOKENS: 'CLEAR_PENDING_TOKENS', + setPendingTokens, + clearPendingTokens, } module.exports = actions @@ -1929,3 +1933,22 @@ function updateNetworkEndpointType (networkEndpointType) { value: networkEndpointType, } } + +function setPendingTokens (pendingTokens) { + const { customToken = {}, selectedTokens = {} } = pendingTokens + const { address, symbol, decimals } = customToken + const tokens = address && symbol && decimals + ? { ...selectedTokens, [address]: { ...customToken, isCustom: true } } + : selectedTokens + + return { + type: actions.SET_PENDING_TOKENS, + payload: tokens, + } +} + +function clearPendingTokens () { + return { + type: actions.CLEAR_PENDING_TOKENS, + } +} diff --git a/ui/app/app.js b/ui/app/app.js index f840cc34e..3d2961340 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -26,6 +26,7 @@ const UnlockPage = require('./components/pages/unlock-page') const RestoreVaultPage = require('./components/pages/keychains/restore-vault') const RevealSeedConfirmation = require('./components/pages/keychains/reveal-seed') const AddTokenPage = require('./components/pages/add-token') +const ConfirmAddTokenPage = require('./components/pages/confirm-add-token') const CreateAccountPage = require('./components/pages/create-account') const NoticeScreen = require('./components/pages/notice') @@ -47,6 +48,7 @@ const { REVEAL_SEED_ROUTE, RESTORE_VAULT_ROUTE, ADD_TOKEN_ROUTE, + CONFIRM_ADD_TOKEN_ROUTE, NEW_ACCOUNT_ROUTE, SEND_ROUTE, CONFIRM_TRANSACTION_ROUTE, @@ -77,6 +79,7 @@ class App extends Component { h(Authenticated, { path: CONFIRM_TRANSACTION_ROUTE, component: ConfirmTxScreen }), h(Authenticated, { path: SEND_ROUTE, exact, component: SendTransactionScreen2 }), h(Authenticated, { path: ADD_TOKEN_ROUTE, exact, component: AddTokenPage }), + h(Authenticated, { path: CONFIRM_ADD_TOKEN_ROUTE, exact, component: ConfirmAddTokenPage }), h(Authenticated, { path: NEW_ACCOUNT_ROUTE, component: CreateAccountPage }), h(Authenticated, { path: DEFAULT_ROUTE, exact, component: Home }), ]) diff --git a/ui/app/components/button/button.component.js b/ui/app/components/button/button.component.js index 7769e4875..fe3bf363c 100644 --- a/ui/app/components/button/button.component.js +++ b/ui/app/components/button/button.component.js @@ -1,7 +1,6 @@ -const { Component } = require('react') -const h = require('react-hyperscript') -const PropTypes = require('prop-types') -const classnames = require('classnames') +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' const SECONDARY = 'secondary' const CLASSNAME_PRIMARY = 'btn-primary' @@ -24,10 +23,12 @@ class Button extends Component { const { type, large, className, ...buttonProps } = this.props return ( - h('button', { - className: classnames(getClassName(type, large), className), - ...buttonProps, - }, this.props.children) + ) } } @@ -39,5 +40,5 @@ Button.propTypes = { children: PropTypes.string, } -module.exports = Button +export default Button diff --git a/ui/app/components/button/index.js b/ui/app/components/button/index.js index 3dc7d1eea..33ae95ae2 100644 --- a/ui/app/components/button/index.js +++ b/ui/app/components/button/index.js @@ -1,2 +1,2 @@ -const Button = require('./button.component') +import Button from './button.component' module.exports = Button diff --git a/ui/app/components/export-text-container/export-text-container.scss b/ui/app/components/export-text-container/index.scss similarity index 100% rename from ui/app/components/export-text-container/export-text-container.scss rename to ui/app/components/export-text-container/index.scss diff --git a/ui/app/components/index.scss b/ui/app/components/index.scss new file mode 100644 index 000000000..f3fe823f8 --- /dev/null +++ b/ui/app/components/index.scss @@ -0,0 +1,5 @@ +@import './export-text-container/index'; + +@import './info-box/index'; + +@import './pages/index'; diff --git a/ui/app/components/info-box/index.js b/ui/app/components/info-box/index.js new file mode 100644 index 000000000..6110422ed --- /dev/null +++ b/ui/app/components/info-box/index.js @@ -0,0 +1,2 @@ +import InfoBox from './info-box.component' +module.exports = InfoBox diff --git a/ui/app/components/info-box/index.scss b/ui/app/components/info-box/index.scss new file mode 100644 index 000000000..8b5626d79 --- /dev/null +++ b/ui/app/components/info-box/index.scss @@ -0,0 +1,24 @@ +.info-box { + border-radius: 4px; + background-color: $alabaster; + position: relative; + padding: 16px; + display: flex; + flex-flow: column; + color: $mid-gray; + + &__close::after { + content: '\00D7'; + font-size: 29px; + font-weight: 200; + color: $dusty-gray; + position: absolute; + right: 12px; + top: 0; + cursor: pointer; + } + + &__description { + font-size: .75rem; + } +} diff --git a/ui/app/components/info-box/info-box.component.js b/ui/app/components/info-box/info-box.component.js new file mode 100644 index 000000000..8688b8e8f --- /dev/null +++ b/ui/app/components/info-box/info-box.component.js @@ -0,0 +1,49 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +export default class InfoBox extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + onClose: PropTypes.func, + title: PropTypes.string, + description: PropTypes.string, + } + + constructor (props) { + super(props) + + this.state = { + isShowing: true, + } + } + + handleClose () { + const { onClose } = this.props + + if (onClose) { + onClose() + } else { + this.setState({ isShowing: false }) + } + } + + render () { + const { title, description } = this.props + + return !this.state.isShowing + ? null + : ( +
+
this.handleClose()} + /> +
{ title }
+
{ description }
+
+ ) + } +} diff --git a/ui/app/components/pages/add-token.js b/ui/app/components/pages/add-token.js deleted file mode 100644 index 8d52571d0..000000000 --- a/ui/app/components/pages/add-token.js +++ /dev/null @@ -1,431 +0,0 @@ -const inherits = require('util').inherits -const Component = require('react').Component -const classnames = require('classnames') -const h = require('react-hyperscript') -const PropTypes = require('prop-types') -const connect = require('react-redux').connect -const R = require('ramda') -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) - .filter(tokenData => Boolean(tokenData.erc20)) -const fuse = new Fuse(contractList, { - shouldSort: true, - threshold: 0.45, - location: 0, - distance: 100, - maxPatternLength: 32, - minMatchCharLength: 1, - keys: [ - { name: 'name', weight: 0.5 }, - { name: 'symbol', weight: 0.5 }, - ], -}) -const actions = require('../../actions') -const ethUtil = require('ethereumjs-util') -const { tokenInfoGetter } = require('../../token-util') -const { DEFAULT_ROUTE } = require('../../routes') - -const emptyAddr = '0x0000000000000000000000000000000000000000' - -AddTokenScreen.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(AddTokenScreen) - - -function mapStateToProps (state) { - const { identities, tokens } = state.metamask - return { - identities, - tokens, - } -} - -function mapDispatchToProps (dispatch) { - return { - addTokens: tokens => dispatch(actions.addTokens(tokens)), - } -} - -inherits(AddTokenScreen, Component) -function AddTokenScreen () { - this.state = { - isShowingConfirmation: false, - isShowingInfoBox: true, - customAddress: '', - customSymbol: '', - customDecimals: '', - searchQuery: '', - selectedTokens: {}, - errors: {}, - autoFilled: false, - displayedTab: 'SEARCH', - } - this.tokenAddressDidChange = this.tokenAddressDidChange.bind(this) - this.tokenSymbolDidChange = this.tokenSymbolDidChange.bind(this) - this.tokenDecimalsDidChange = this.tokenDecimalsDidChange.bind(this) - this.onNext = this.onNext.bind(this) - Component.call(this) -} - -AddTokenScreen.prototype.componentWillMount = function () { - this.tokenInfoGetter = tokenInfoGetter() -} - -AddTokenScreen.prototype.toggleToken = function (address, token) { - const { selectedTokens = {}, errors } = this.state - const selectedTokensCopy = { ...selectedTokens } - - if (address in selectedTokensCopy) { - delete selectedTokensCopy[address] - } else { - selectedTokensCopy[address] = token - } - - this.setState({ - selectedTokens: selectedTokensCopy, - 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 }) - if (ethUtil.isValidAddress(customAddress) && customAddress !== emptyAddr) { - this.attemptToAutoFillTokenParams(customAddress) - } else { - this.setState({ - customSymbol: '', - customDecimals: 0, - }) - } -} - -AddTokenScreen.prototype.tokenSymbolDidChange = function (e) { - const customSymbol = e.target.value.trim() - this.setState({ customSymbol }) -} - -AddTokenScreen.prototype.tokenDecimalsDidChange = function (e) { - const customDecimals = e.target.value.trim() - this.setState({ customDecimals }) -} - -AddTokenScreen.prototype.checkExistingAddresses = function (address) { - if (!address) return false - const tokensList = this.props.tokens - const matchesAddress = existingToken => { - return existingToken.address.toLowerCase() === address.toLowerCase() - } - - return R.any(matchesAddress)(tokensList) -} - -AddTokenScreen.prototype.validate = function () { - const errors = {} - const identitiesList = Object.keys(this.props.identities) - const { customAddress, customSymbol, customDecimals, selectedTokens } = this.state - const standardAddress = ethUtil.addHexPrefix(customAddress).toLowerCase() - - if (customAddress) { - const validAddress = ethUtil.isValidAddress(customAddress) - if (!validAddress) { - errors.customAddress = this.context.t('invalidAddress') - } - - const validDecimals = customDecimals !== null - && customDecimals !== '' - && customDecimals >= 0 - && customDecimals < 36 - if (!validDecimals) { - errors.customDecimals = this.context.t('decimalsMustZerotoTen') - } - - const symbolLen = customSymbol.trim().length - const validSymbol = symbolLen > 0 && symbolLen < 10 - if (!validSymbol) { - errors.customSymbol = this.context.t('symbolBetweenZeroTen') - } - - const ownAddress = identitiesList.includes(standardAddress) - if (ownAddress) { - errors.customAddress = this.context.t('personalAddressDetected') - } - - const tokenAlreadyAdded = this.checkExistingAddresses(customAddress) - if (tokenAlreadyAdded) { - errors.customAddress = this.context.t('tokenAlreadyAdded') - } - } else if ( - Object.entries(selectedTokens) - .reduce((isEmpty, [ symbol, isSelected ]) => ( - isEmpty && !isSelected - ), true) - ) { - errors.tokenSelector = this.context.t('mustSelectOne') - } - - return { - isValid: !Object.keys(errors).length, - errors, - } -} - -AddTokenScreen.prototype.attemptToAutoFillTokenParams = async function (address) { - const { symbol, decimals } = await this.tokenInfoGetter(address) - if (symbol && decimals) { - this.setState({ - customSymbol: symbol, - customDecimals: decimals, - autoFilled: true, - }) - } -} - -AddTokenScreen.prototype.renderCustomForm = function () { - const { autoFilled, customAddress, customSymbol, customDecimals, errors } = this.state - - return ( - 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', this.context.t('tokenAddress')), - 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', this.context.t('tokenSymbol')), - h('input.add-token__add-custom-input', { - type: 'text', - onChange: this.tokenSymbolDidChange, - value: customSymbol, - disabled: autoFilled, - }), - 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', this.context.t('decimal')), - h('input.add-token__add-custom-input', { - type: 'number', - onChange: this.tokenDecimalsDidChange, - value: customDecimals, - disabled: autoFilled, - }), - h('div.add-token__add-custom-error-message', errors.customDecimals), - ]), - ]) - ) -} - -AddTokenScreen.prototype.renderTokenList = function () { - const { searchQuery = '', selectedTokens } = this.state - const fuseSearchResult = fuse.search(searchQuery) - const addressSearchResult = contractList.filter(token => { - return token.address.toLowerCase() === searchQuery.toLowerCase() - }) - const results = [...addressSearchResult, ...fuseSearchResult] - - return h('div', [ - results.length > 0 && h('div.add-token__token-icons-title', this.context.t('popularTokens')), - h('div.add-token__token-icons-container', Array(6).fill(undefined) - .map((_, i) => { - const { logo, symbol, name, address } = results[i] || {} - const tokenAlreadyAdded = this.checkExistingAddresses(address) - return Boolean(logo || symbol || name) && ( - h('div.add-token__token-wrapper', { - className: classnames({ - 'add-token__token-wrapper--selected': selectedTokens[address], - 'add-token__token-wrapper--disabled': tokenAlreadyAdded, - }), - onClick: () => !tokenAlreadyAdded && this.toggleToken(address, results[i]), - }, [ - h('div.add-token__token-icon', { - style: { - backgroundImage: logo && `url(images/contract/${logo})`, - }, - }), - h('div.add-token__token-data', [ - h('div.add-token__token-symbol', symbol), - h('div.add-token__token-name', name), - ]), - // tokenAlreadyAdded && ( - // h('div.add-token__token-message', 'Already added') - // ), - ]) - ) - })), - ]) -} - -AddTokenScreen.prototype.renderConfirmation = function () { - const { - customAddress: address, - customSymbol: symbol, - customDecimals: decimals, - selectedTokens, - } = this.state - - const { addTokens, history } = 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__content-container.add-token__confirmation-content', [ - h('div.add-token__description.add-token__confirmation-description', this.context.t('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--lg.add-token__cancel-button', { - onClick: () => this.setState({ isShowingConfirmation: false }), - }, this.context.t('back')), - h('button.btn-primary--lg', { - onClick: () => addTokens(tokens).then(() => history.push(DEFAULT_ROUTE)), - }, this.context.t('addTokens')), - ]), - ]) - ) -} - -AddTokenScreen.prototype.displayTab = function (selectedTab) { - this.setState({ displayedTab: selectedTab }) -} - -AddTokenScreen.prototype.renderTabs = function () { - const { isShowingInfoBox, displayedTab, errors } = this.state - - return displayedTab === 'CUSTOM_TOKEN' - ? this.renderCustomForm() - : h('div', [ - h('div.add-token__wrapper', [ - h('div.add-token__content-container', [ - isShowingInfoBox && h('div.add-token__info-box', [ - h('div.add-token__info-box__close', { - onClick: () => this.setState({ isShowingInfoBox: false }), - }), - h('div.add-token__info-box__title', this.context.t('whatsThis')), - h('div.add-token__info-box__copy', this.context.t('keepTrackTokens')), - h('a.add-token__info-box__copy--blue', { - href: 'http://metamask.helpscoutdocs.com/article/16-managing-erc20-tokens', - target: '_blank', - }, this.context.t('learnMore')), - ]), - h('div.add-token__input-container', [ - h('input.add-token__input', { - type: 'text', - placeholder: this.context.t('searchTokens'), - onChange: e => this.setState({ searchQuery: e.target.value }), - }), - h('div.add-token__search-input-error-message', errors.tokenSelector), - ]), - this.renderTokenList(), - ]), - ]), - ]) -} - -AddTokenScreen.prototype.render = function () { - const { - isShowingConfirmation, - displayedTab, - } = this.state - const { history } = this.props - - return h('div.add-token', [ - h('div.add-token__header', [ - h('div.add-token__header__cancel', { - onClick: () => history.push(DEFAULT_ROUTE), - }, [ - h('i.fa.fa-angle-left.fa-lg'), - h('span', this.context.t('cancel')), - ]), - h('div.add-token__header__title', this.context.t('addTokens')), - isShowingConfirmation && h('div.add-token__header__subtitle', this.context.t('likeToAddTokens')), - !isShowingConfirmation && h('div.add-token__header__tabs', [ - - h('div.add-token__header__tabs__tab', { - className: classnames('add-token__header__tabs__tab', { - 'add-token__header__tabs__selected': displayedTab === 'SEARCH', - 'add-token__header__tabs__unselected': displayedTab !== 'SEARCH', - }), - onClick: () => this.displayTab('SEARCH'), - }, this.context.t('search')), - - h('div.add-token__header__tabs__tab', { - className: classnames('add-token__header__tabs__tab', { - 'add-token__header__tabs__selected': displayedTab === 'CUSTOM_TOKEN', - 'add-token__header__tabs__unselected': displayedTab !== 'CUSTOM_TOKEN', - }), - onClick: () => this.displayTab('CUSTOM_TOKEN'), - }, this.context.t('customToken')), - - ]), - ]), - - isShowingConfirmation - ? this.renderConfirmation() - : this.renderTabs(), - - !isShowingConfirmation && h('div.add-token__buttons', [ - h('button.btn-secondary--lg.add-token__cancel-button', { - onClick: () => history.push(DEFAULT_ROUTE), - }, this.context.t('cancel')), - h('button.btn-primary--lg.add-token__confirm-button', { - onClick: this.onNext, - }, this.context.t('next')), - ]), - ]) -} diff --git a/ui/app/components/pages/add-token/add-token.component.js b/ui/app/components/pages/add-token/add-token.component.js new file mode 100644 index 000000000..885c7b2ac --- /dev/null +++ b/ui/app/components/pages/add-token/add-token.component.js @@ -0,0 +1,356 @@ +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' + +const emptyAddr = '0x0000000000000000000000000000000000000000' +const SEARCH_TAB = 'SEARCH' +const CUSTOM_TOKEN_TAB = 'CUSTOM_TOKEN' + +class AddToken extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + setPendingTokens: PropTypes.func, + pendingTokens: PropTypes.object, + clearPendingTokens: PropTypes.func, + tokens: PropTypes.array, + identities: PropTypes.object, + } + + constructor (props) { + super(props) + + this.state = { + customAddress: '', + customSymbol: '', + customDecimals: 0, + searchResults: [], + selectedTokens: {}, + tokenSelectorError: null, + customAddressError: null, + customSymbolError: null, + customDecimalsError: null, + autoFilled: false, + displayedTab: SEARCH_TAB, + } + } + + componentDidMount () { + this.tokenInfoGetter = tokenInfoGetter() + const { pendingTokens = {} } = this.props + const pendingTokenKeys = Object.keys(pendingTokens) + + if (pendingTokenKeys.length > 0) { + let selectedTokens = {} + let customToken = {} + + pendingTokenKeys.forEach(tokenAddress => { + const token = pendingTokens[tokenAddress] + const { isCustom } = token + + if (isCustom) { + customToken = { ...token } + } else { + selectedTokens = { ...selectedTokens, [tokenAddress]: { ...token } } + } + }) + + const { + address: customAddress = '', + symbol: customSymbol = '', + decimals: customDecimals = 0, + } = customToken + + const displayedTab = Object.keys(selectedTokens).length > 0 ? SEARCH_TAB : CUSTOM_TOKEN_TAB + this.setState({ selectedTokens, customAddress, customSymbol, customDecimals, displayedTab }) + } + } + + handleToggleToken (token) { + const { address } = token + const { selectedTokens = {} } = this.state + const selectedTokensCopy = { ...selectedTokens } + + if (address in selectedTokensCopy) { + delete selectedTokensCopy[address] + } else { + selectedTokensCopy[address] = token + } + + this.setState({ + selectedTokens: selectedTokensCopy, + tokenSelectorError: null, + }) + } + + hasError () { + const { + tokenSelectorError, + customAddressError, + customSymbolError, + customDecimalsError, + } = this.state + + return tokenSelectorError || customAddressError || customSymbolError || customDecimalsError + } + + hasSelected () { + const { customAddress = '', selectedTokens = {} } = this.state + return customAddress || Object.keys(selectedTokens).length > 0 + } + + handleNext () { + if (this.hasError()) { + return + } + + if (!this.hasSelected()) { + this.setState({ tokenSelectorError: this.context.t('mustSelectOne') }) + return + } + + const { setPendingTokens, history } = this.props + const { + customAddress: address, + customSymbol: symbol, + customDecimals: decimals, + selectedTokens, + } = this.state + + const customToken = { + address, + symbol, + decimals, + } + + setPendingTokens({ customToken, selectedTokens }) + history.push(CONFIRM_ADD_TOKEN_ROUTE) + } + + async attemptToAutoFillTokenParams (address) { + const { symbol, decimals } = await this.tokenInfoGetter(address) + + if (symbol && decimals) { + this.setState({ + customSymbol: symbol, + customDecimals: decimals, + customSymbolError: null, + customDecimalsError: null, + autoFilled: true, + }) + } + } + + handleCustomAddressChange (value) { + const customAddress = value.trim() + this.setState({ + customAddress, + customAddressError: null, + tokenSelectorError: null, + autoFilled: false, + }) + + const isValidAddress = ethUtil.isValidAddress(customAddress) + const standardAddress = ethUtil.addHexPrefix(customAddress).toLowerCase() + + switch (true) { + case !isValidAddress: + this.setState({ + customAddressError: this.context.t('invalidAddress'), + customSymbol: '', + customDecimals: 0, + customSymbolError: null, + customDecimalsError: null, + }) + + break + case Boolean(this.props.identities[standardAddress]): + this.setState({ + customAddressError: this.context.t('personalAddressDetected'), + }) + + break + case checkExistingAddresses(customAddress, this.props.tokens): + this.setState({ + customAddressError: this.context.t('tokenAlreadyAdded'), + }) + + break + default: + if (customAddress !== emptyAddr) { + this.attemptToAutoFillTokenParams(customAddress) + } + } + } + + handleCustomSymbolChange (value) { + const customSymbol = value.trim() + const symbolLength = customSymbol.length + let customSymbolError = null + + if (symbolLength <= 0 || symbolLength >= 10) { + customSymbolError = this.context.t('symbolBetweenZeroTen') + } + + this.setState({ customSymbol, customSymbolError }) + } + + handleCustomDecimalsChange (value) { + const customDecimals = value.trim() + const validDecimals = customDecimals !== null && + customDecimals !== '' && + customDecimals >= 0 && + customDecimals < 36 + let customDecimalsError = null + + if (!validDecimals) { + customDecimalsError = this.context.t('decimalsMustZerotoTen') + } + + this.setState({ customDecimals, customDecimalsError }) + } + + renderCustomTokenForm () { + const { + customAddress, + customSymbol, + customDecimals, + customAddressError, + customSymbolError, + customDecimalsError, + autoFilled, + } = this.state + + return ( +
+ this.handleCustomAddressChange(e.target.value)} + error={customAddressError} + fullWidth + margin="normal" + /> + this.handleCustomSymbolChange(e.target.value)} + error={customSymbolError} + fullWidth + margin="normal" + disabled={autoFilled} + /> + this.handleCustomDecimalsChange(e.target.value)} + error={customDecimalsError} + fullWidth + margin="normal" + disabled={autoFilled} + /> +
+ ) + } + + renderSearchToken () { + const { tokenSelectorError, selectedTokens, searchResults } = this.state + + return ( +
+ this.setState({ searchResults: results })} + error={tokenSelectorError} + /> +
+ this.handleToggleToken(token)} + /> +
+
+ ) + } + + render () { + const { displayedTab } = this.state + const { history, clearPendingTokens } = this.props + + return ( +
+
+
+ { this.context.t('addTokens') } +
+
+
this.setState({ displayedTab: SEARCH_TAB })} + > + { this.context.t('search') } +
+
this.setState({ displayedTab: CUSTOM_TOKEN_TAB })} + > + { this.context.t('customToken') } +
+
+
+
+ { + displayedTab === CUSTOM_TOKEN_TAB + ? this.renderCustomTokenForm() + : this.renderSearchToken() + } +
+
+ + +
+
+ ) + } +} + +export default AddToken diff --git a/ui/app/components/pages/add-token/add-token.container.js b/ui/app/components/pages/add-token/add-token.container.js new file mode 100644 index 000000000..87671b156 --- /dev/null +++ b/ui/app/components/pages/add-token/add-token.container.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux' +import AddToken from './add-token.component' + +const { setPendingTokens, clearPendingTokens } = require('../../../actions') + +const mapStateToProps = ({ metamask }) => { + const { identities, tokens, pendingTokens } = metamask + return { + identities, + tokens, + pendingTokens, + } +} + +const mapDispatchToProps = dispatch => { + return { + setPendingTokens: tokens => dispatch(setPendingTokens(tokens)), + clearPendingTokens: () => dispatch(clearPendingTokens()), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(AddToken) diff --git a/ui/app/components/pages/add-token/index.js b/ui/app/components/pages/add-token/index.js new file mode 100644 index 000000000..3666cae82 --- /dev/null +++ b/ui/app/components/pages/add-token/index.js @@ -0,0 +1,2 @@ +import AddToken from './add-token.container' +module.exports = AddToken diff --git a/ui/app/components/pages/add-token/index.scss b/ui/app/components/pages/add-token/index.scss new file mode 100644 index 000000000..39e86b97b --- /dev/null +++ b/ui/app/components/pages/add-token/index.scss @@ -0,0 +1,25 @@ +@import './token-list/index'; + +.add-token { + &__custom-token-form { + padding: 8px 16px 16px; + + input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + display: none; + } + + input[type="number"]:hover::-webkit-inner-spin-button { + -webkit-appearance: none; + display: none; + } + } + + &__search-token { + padding: 16px; + } + + &__token-list { + margin-top: 16px; + } +} diff --git a/ui/app/components/pages/add-token/token-list/index.js b/ui/app/components/pages/add-token/token-list/index.js new file mode 100644 index 000000000..21dd5ac72 --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/index.js @@ -0,0 +1,2 @@ +import TokenList from './token-list.container' +module.exports = TokenList diff --git a/ui/app/components/pages/add-token/token-list/index.scss b/ui/app/components/pages/add-token/token-list/index.scss new file mode 100644 index 000000000..e32739d59 --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/index.scss @@ -0,0 +1,65 @@ +@import './token-list-placeholder/index'; + +.token-list { + &__title { + font-size: .75rem; + } + + &__tokens-container { + display: flex; + flex-direction: column; + } + + &__token { + transition: 200ms ease-in-out; + display: flex; + flex-flow: row nowrap; + align-items: center; + padding: 8px; + margin-top: 8px; + box-sizing: border-box; + border-radius: 10px; + cursor: pointer; + border: 2px solid transparent; + position: relative; + + &:hover { + border: 2px solid rgba($malibu-blue, .5); + } + + &--selected { + border: 2px solid $malibu-blue !important; + } + + &--disabled { + opacity: .4; + pointer-events: none; + } + } + + &__token-icon { + width: 48px; + height: 48px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + border-radius: 50%; + background-color: $white; + box-shadow: 0 2px 4px 0 rgba($black, .24); + margin-right: 12px; + flex: 0 0 auto; + } + + &__token-data { + display: flex; + flex-direction: row; + align-items: center; + min-width: 0; + } + + &__token-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.js b/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.js new file mode 100644 index 000000000..b82f45e93 --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.js @@ -0,0 +1,2 @@ +import TokenListPlaceholder from './token-list-placeholder.component' +module.exports = TokenListPlaceholder diff --git a/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.scss b/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.scss new file mode 100644 index 000000000..9d0f4be32 --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/token-list-placeholder/index.scss @@ -0,0 +1,19 @@ +.token-list-placeholder { + display: flex; + align-items: center; + padding-top: 36px; + flex-direction: column; + line-height: 22px; + opacity: .5; + + &__text { + color: $silver-chalice; + width: 50%; + text-align: center; + margin-top: 8px; + } + + &__link { + color: $curious-blue; + } +} 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 new file mode 100644 index 000000000..abd599b26 --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/token-list-placeholder/token-list-placeholder.component.js @@ -0,0 +1,27 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +export default class TokenListPlaceholder extends Component { + static contextTypes = { + t: PropTypes.func, + } + + render () { + return ( +
+ +
+ { this.context.t('addAcquiredTokens') } +
+ + { this.context.t('learnMore') } + +
+ ) + } +} diff --git a/ui/app/components/pages/add-token/token-list/token-list.component.js b/ui/app/components/pages/add-token/token-list/token-list.component.js new file mode 100644 index 000000000..724a68d6e --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/token-list.component.js @@ -0,0 +1,60 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { checkExistingAddresses } from '../util' +import TokenListPlaceholder from './token-list-placeholder' + +export default class InfoBox extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + tokens: PropTypes.array, + results: PropTypes.array, + selectedTokens: PropTypes.object, + onToggleToken: PropTypes.func, + } + + render () { + const { results = [], selectedTokens = {}, onToggleToken, tokens = [] } = this.props + + return results.length === 0 + ? + : ( +
+
+ { this.context.t('searchResults') } +
+
+ { + Array(6).fill(undefined) + .map((_, i) => { + const { logo, symbol, name, address } = results[i] || {} + const tokenAlreadyAdded = checkExistingAddresses(address, tokens) + + return Boolean(logo || symbol || name) && ( +
!tokenAlreadyAdded && onToggleToken(results[i])} + key={i} + > +
+
+
+ { `${name} (${symbol})` } +
+
+ ) + }) + } +
+
+ ) + } +} diff --git a/ui/app/components/pages/add-token/token-list/token-list.container.js b/ui/app/components/pages/add-token/token-list/token-list.container.js new file mode 100644 index 000000000..cd7b07a37 --- /dev/null +++ b/ui/app/components/pages/add-token/token-list/token-list.container.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux' +import TokenList from './token-list.component' + +const mapStateToProps = ({ metamask }) => { + const { tokens } = metamask + return { + tokens, + } +} + +export default connect(mapStateToProps)(TokenList) diff --git a/ui/app/components/pages/add-token/token-search/index.js b/ui/app/components/pages/add-token/token-search/index.js new file mode 100644 index 000000000..acaa6b084 --- /dev/null +++ b/ui/app/components/pages/add-token/token-search/index.js @@ -0,0 +1,2 @@ +import TokenSearch from './token-search.component' +module.exports = TokenSearch diff --git a/ui/app/components/pages/add-token/token-search/token-search.component.js b/ui/app/components/pages/add-token/token-search/token-search.component.js new file mode 100644 index 000000000..036b2db1e --- /dev/null +++ b/ui/app/components/pages/add-token/token-search/token-search.component.js @@ -0,0 +1,85 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import contractMap from 'eth-contract-metadata' +import Fuse from 'fuse.js' +import InputAdornment from '@material-ui/core/InputAdornment' +import TextField from '../../../text-field' + +const contractList = Object.entries(contractMap) + .map(([ _, tokenData]) => tokenData) + .filter(tokenData => Boolean(tokenData.erc20)) + +const fuse = new Fuse(contractList, { + shouldSort: true, + threshold: 0.45, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: [ + { name: 'name', weight: 0.5 }, + { name: 'symbol', weight: 0.5 }, + ], +}) + +export default class TokenSearch extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static defaultProps = { + error: null, + } + + static propTypes = { + onSearch: PropTypes.func, + error: PropTypes.string, + } + + constructor (props) { + super(props) + + this.state = { + searchQuery: '', + } + } + + handleSearch (searchQuery) { + this.setState({ searchQuery }) + const fuseSearchResult = fuse.search(searchQuery) + const addressSearchResult = contractList.filter(token => { + return token.address.toLowerCase() === searchQuery.toLowerCase() + }) + const results = [...addressSearchResult, ...fuseSearchResult] + this.props.onSearch({ searchQuery, results }) + } + + renderAdornment () { + return ( + + + + ) + } + + render () { + const { error } = this.props + const { searchQuery } = this.state + + return ( + this.handleSearch(e.target.value)} + error={error} + fullWidth + startAdornment={this.renderAdornment()} + /> + ) + } +} diff --git a/ui/app/components/pages/add-token/util.js b/ui/app/components/pages/add-token/util.js new file mode 100644 index 000000000..579c56cc0 --- /dev/null +++ b/ui/app/components/pages/add-token/util.js @@ -0,0 +1,13 @@ +import R from 'ramda' + +export function checkExistingAddresses (address, tokenList = []) { + if (!address) { + return false + } + + const matchesAddress = existingToken => { + return existingToken.address.toLowerCase() === address.toLowerCase() + } + + return R.any(matchesAddress)(tokenList) +} 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 new file mode 100644 index 000000000..9db9efc37 --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/confirm-add-token.component.js @@ -0,0 +1,115 @@ +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' + +export default class ConfirmAddToken extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + clearPendingTokens: PropTypes.func, + addTokens: PropTypes.func, + pendingTokens: PropTypes.object, + } + + 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 { history, addTokens, clearPendingTokens, pendingTokens } = this.props + + return ( +
+
+
+ { this.context.t('addTokens') } +
+
+ { this.context.t('likeToAddTokens') } +
+
+
+
+
+
+ { this.context.t('token') } +
+
+ { this.context.t('balance') } +
+
+
+ { + Object.entries(pendingTokens) + .map(([ address, token ]) => { + const { name, symbol } = token + + return ( +
+
+ +
+ { this.getTokenName(name, symbol) } +
+
+
+ +
+
+ ) + }) + } +
+
+
+
+ + +
+
+ ) + } +} diff --git a/ui/app/components/pages/confirm-add-token/confirm-add-token.container.js b/ui/app/components/pages/confirm-add-token/confirm-add-token.container.js new file mode 100644 index 000000000..0190024d9 --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/confirm-add-token.container.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux' +import ConfirmAddToken from './confirm-add-token.component' + +const { addTokens, clearPendingTokens } = require('../../../actions') + +const mapStateToProps = ({ metamask }) => { + const { pendingTokens } = metamask + return { + pendingTokens, + } +} + +const mapDispatchToProps = dispatch => { + return { + addTokens: tokens => dispatch(addTokens(tokens)), + clearPendingTokens: () => dispatch(clearPendingTokens()), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(ConfirmAddToken) diff --git a/ui/app/components/pages/confirm-add-token/index.js b/ui/app/components/pages/confirm-add-token/index.js new file mode 100644 index 000000000..b7decabec --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/index.js @@ -0,0 +1,2 @@ +import ConfirmAddToken from './confirm-add-token.container' +module.exports = ConfirmAddToken diff --git a/ui/app/components/pages/confirm-add-token/index.scss b/ui/app/components/pages/confirm-add-token/index.scss new file mode 100644 index 000000000..66146cf78 --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/index.scss @@ -0,0 +1,69 @@ +.confirm-add-token { + padding: 16px; + + &__header { + font-size: .75rem; + display: flex; + } + + &__token { + flex: 1; + min-width: 0; + } + + &__balance { + flex: 0 0 30%; + min-width: 0; + } + + &__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; + line-height: 43px; + margin-right: 8px; + } + + &__symbol { + color: $scorpion; + font-size: 16px; + font-weight: 400; + line-height: 24px; + } + } + } + + &__token-list-item { + display: flex; + flex-flow: row nowrap; + align-items: center; + margin-top: 8px; + box-sizing: border-box; + } + + &__data { + display: flex; + align-items: center; + padding: 8px; + } + + &__name { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__token-icon { + margin-right: 12px; + flex: 0 0 auto; + } +} 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 new file mode 100644 index 000000000..6fb5c8223 --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/token-balance/index.js @@ -0,0 +1,2 @@ +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 new file mode 100644 index 000000000..976788d4c --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/token-balance/token-balance.component.js @@ -0,0 +1,16 @@ +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 ( +
{ this.props.string }
+ ) + } +} diff --git a/ui/app/components/pages/confirm-add-token/token-balance/token-balance.container.js b/ui/app/components/pages/confirm-add-token/token-balance/token-balance.container.js new file mode 100644 index 000000000..bc1289ce1 --- /dev/null +++ b/ui/app/components/pages/confirm-add-token/token-balance/token-balance.container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import withTokenTracker from '../../../../helpers/with-token-tracker' +import TokenBalance from './token-balance.component' +import selectors from '../../../../selectors' + +const mapStateToProps = state => { + return { + userAddress: selectors.getSelectedAddress(state), + } +} + +export default compose( + connect(mapStateToProps), + withTokenTracker +)(TokenBalance) diff --git a/ui/app/components/pages/index.scss b/ui/app/components/pages/index.scss new file mode 100644 index 000000000..b15c59863 --- /dev/null +++ b/ui/app/components/pages/index.scss @@ -0,0 +1,5 @@ +@import './unlock-page/index'; + +@import './add-token/index'; + +@import './confirm-add-token/index'; diff --git a/ui/app/components/pages/unlock-page/unlock-page.scss b/ui/app/components/pages/unlock-page/index.scss similarity index 100% rename from ui/app/components/pages/unlock-page/unlock-page.scss rename to ui/app/components/pages/unlock-page/index.scss diff --git a/ui/app/components/pages/unlock-page/unlock-page.component.js b/ui/app/components/pages/unlock-page/unlock-page.component.js index 0976d9506..a2f009d8f 100644 --- a/ui/app/components/pages/unlock-page/unlock-page.component.js +++ b/ui/app/components/pages/unlock-page/unlock-page.component.js @@ -1,6 +1,6 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import Button from 'material-ui/Button' +import Button from '@material-ui/core/Button' import TextField from '../../text-field' const { ENVIRONMENT_TYPE_POPUP } = require('../../../../../app/scripts/lib/enums') @@ -129,6 +129,7 @@ class UnlockPage extends Component { error={error} autoFocus autoComplete="current-password" + material fullWidth /> diff --git a/ui/app/components/signature-request.js b/ui/app/components/signature-request.js index b958a2d2d..474fcf439 100644 --- a/ui/app/components/signature-request.js +++ b/ui/app/components/signature-request.js @@ -115,7 +115,7 @@ SignatureRequest.prototype.renderBalance = function () { return h('div.request-signature__balance', [ - h('div.request-signature__balance-text', [this.context.t('balance')]), + h('div.request-signature__balance-text', `${this.context.t('balance')}:`), h('div.request-signature__balance-value', `${balanceInEther} ETH`), diff --git a/ui/app/components/text-field/text-field.component.js b/ui/app/components/text-field/text-field.component.js index 6fd3b82b4..4107068b5 100644 --- a/ui/app/components/text-field/text-field.component.js +++ b/ui/app/components/text-field/text-field.component.js @@ -1,59 +1,100 @@ -import React from 'react' +import React, { Component } from 'react' import PropTypes from 'prop-types' -import { withStyles } from 'material-ui/styles' -import { default as MaterialTextField } from 'material-ui/TextField' +import { withStyles } from '@material-ui/core/styles' +import { default as MaterialTextField } from '@material-ui/core/TextField' const styles = { - cssLabel: { - '&$cssFocused': { + materialLabel: { + '&$materialFocused': { color: '#aeaeae', }, - '&$cssError': { + '&$materialError': { color: '#aeaeae', }, fontWeight: '400', color: '#aeaeae', }, - cssFocused: {}, - cssUnderline: { + materialFocused: {}, + materialUnderline: { '&:after': { - backgroundColor: '#f7861c', + borderBottom: '2px solid #f7861c', }, }, - cssError: {}, + materialError: {}, + // Non-material styles + formLabel: { + '&$formLabelFocused': { + color: '#5b5b5b', + }, + '&$materialError': { + color: '#5b5b5b', + }, + }, + formLabelFocused: {}, + inputRoot: { + 'label + &': { + marginTop: '8px', + }, + border: '1px solid #d2d8dd', + height: '48px', + borderRadius: '4px', + padding: '0 16px', + display: 'flex', + alignItems: 'center', + '&:focus': { + border: '1px solid #2f9ae0', + }, + }, + inputLabel: { + fontSize: '.75rem', + transform: 'none', + transition: 'none', + position: 'initial', + color: '#5b5b5b', + }, } -const TextField = props => { - const { error, classes, ...textFieldProps } = props +class TextField extends Component { + static defaultProps = { + error: null, + } - return ( - - ) -} + static propTypes = { + error: PropTypes.string, + classes: PropTypes.object, + material: PropTypes.bool, + startAdornment: PropTypes.element, + } -TextField.defaultProps = { - error: null, -} + render () { + const { error, classes, material, startAdornment, ...textFieldProps } = this.props -TextField.propTypes = { - error: PropTypes.string, - classes: PropTypes.object, + return ( + + ) + } } export default withStyles(styles)(TextField) diff --git a/ui/app/css/itcss/components/add-token.scss b/ui/app/css/itcss/components/add-token.scss deleted file mode 100644 index a3ea0d85b..000000000 --- a/ui/app/css/itcss/components/add-token.scss +++ /dev/null @@ -1,461 +0,0 @@ -.add-token { - width: 498px; - max-height: 805px; - display: flex; - flex-flow: column nowrap; - position: relative; - z-index: 12; - font-family: 'Roboto'; - background: white; - border-radius: 8px; - box-shadow: 0 0 7px 0 rgba(0, 0, 0, 0.08); - - &__wrapper { - background-color: $white; - display: flex; - flex-flow: column nowrap; - align-items: center; - flex: 0 0 auto; - } - - &__header { - display: flex; - flex-flow: column nowrap; - padding: 20px 20px 0px; - border-bottom: 1px solid $geyser; - flex: 0 0 auto; - - &__cancel { - color: $dodger-blue; - display: flex; - align-items: center; - - span { - font-family: Roboto; - font-size: 16px; - font-weight: 400; - line-height: 21px; - margin-left: 8px; - cursor:pointer; - } - } - - &__title { - color: $tundora; - font-size: 32px; - font-weight: 500; - margin-top: 4px; - } - - &__subtitle { - font-weight: 400; - margin-top: 15px; - margin-bottom: 21px; - } - - &__tabs { - display: flex; - - &__tab { - height: 54px; - padding: 15px 10px; - color: $dusty-gray; - font-family: Roboto; - font-size: 18px; - font-weight: 400; - line-height: 24px; - text-align: center; - } - - &__tab:first-of-type { - margin-right: 20px; - } - - &__unselected:hover { - color: $black; - border-bottom: none; - cursor: pointer; - } - - &__selected { - color: $curious-blue; - border-bottom: 3px solid $curious-blue; - } - } - } - - &__info-box { - height: 96px; - margin: 20px 20px 0px; - border-radius: 4px; - background-color: $alabaster; - position: relative; - padding-left: 18px; - display: flex; - flex-flow: column; - - &__close::after { - content: '\00D7'; - font-size: 29px; - font-weight: 200; - color: $dusty-gray; - position: absolute; - right: 17px; - cursor: pointer; - } - - &__title { - color: $mid-gray; - font-family: Roboto; - font-size: 14px; - font-weight: 400; - margin-top: 15px; - margin-bottom: 9px; - } - - &__copy, - &__copy--blue { - color: $mid-gray; - font-family: Roboto; - font-size: 12px; - font-weight: 400; - line-height: 18px; - } - - &__copy--blue { - color: $curious-blue; - } - } - - &__description { - text-align: center; - } - - &__description + &__description { - margin-top: 24px; - } - - &__confirmation-description { - font-weight: 400; - margin: 20px 0 40px 0; - } - - &__content-container { - width: 100%; - } - - &__input-container { - display: flex; - position: relative; - } - - &__search-input-error-message { - position: absolute; - bottom: -10px; - left: 22px; - font-size: 12px; - width: 100%; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - color: $red; - } - - &__input, - &__add-custom-input { - height: 54px; - padding: 0px 20px; - border: 1px solid $geyser; - border-radius: 4px; - margin: 22px 24px; - position: relative; - flex: 1 0 auto; - color: $scorpion; - font-family: Roboto; - font-size: 16px; - - &::placeholder { - color: $scorpion; - font-family: Roboto; - font-size: 16px; - line-height: 21px; - } - } - - &__footers { - width: 100%; - } - - &__add-custom { - color: $scorpion; - font-size: 18px; - line-height: 24px; - text-align: center; - padding: 12px 0; - font-weight: 600; - cursor: pointer; - position: relative; - - &:hover { - background-color: rgba(0, 0, 0, .05); - } - - &:active { - background-color: rgba(0, 0, 0, .1); - } - - .fa { - position: absolute; - right: 24px; - font-size: 24px; - line-height: 24px; - } - } - - &__add-custom-form { - display: flex; - flex-flow: column nowrap; - margin: 40px 0 30px; - } - - &__add-custom-field { - position: relative; - display: flex; - flex-flow: column; - flex: 1 0 auto; - - &--error { - .add-token__add-custom-input { - border-color: $red; - } - } - } - - &__add-custom-error-message { - position: absolute; - bottom: 1px; - left: 22px; - font-size: 12px; - width: 100%; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - color: $red; - } - - &__add-custom-label { - font-size: 16px; - font-weight: 400; - line-height: 21px; - margin-left: 22px; - color: $scorpion; - } - - &__add-custom-input { - margin-top: 6px; - font-size: 16px; - - &::placeholder { - color: $silver; - font-size: 16px; - } - } - - &__add-custom-field + &__add-custom-field { - margin-top: 6px; - } - - &__buttons { - display: flex; - flex-flow: row nowrap; - flex: 0 0 auto; - align-items: center; - justify-content: center; - padding-bottom: 30px; - padding-top: 20px; - } - - &__confirm-button, - &__cancel-button { - margin: 0 12px; - padding: 10px 13px; - height: 54px; - width: 133px; - margin-right: 1.2rem; - } - - &__token-icons-title { - color: #5B5D67; - font-family: Roboto; - font-size: 18px; - font-weight: 400; - line-height: 24px; - margin-left: 24px; - margin-top: 8px; - margin-bottom: 20px; - } - - &__token-icons-container { - display: flex; - flex-flow: row wrap; - } - - &__token-wrapper { - transition: 200ms ease-in-out; - display: flex; - flex-flow: row nowrap; - flex: 0 0 42.5%; - align-items: center; - padding: 12px; - margin: 0% 2.5% 1.5%; - box-sizing: border-box; - border-radius: 10px; - cursor: pointer; - border: 2px solid transparent; - position: relative; - - &:hover { - border: 2px solid rgba($malibu-blue, .5); - } - - &--selected { - border: 2px solid $malibu-blue !important; - } - - &--disabled { - opacity: .4; - pointer-events: none; - } - } - - &__token-data { - align-self: flex-start; - } - - &__token-name { - font-weight: 400; - font-size: 14px; - line-height: 19px; - } - - &__token-symbol { - font-size: 22px; - line-height: 29px; - font-weight: 600; - } - - &__token-icon { - width: 60px; - height: 60px; - background-repeat: no-repeat; - background-size: contain; - background-position: center; - border-radius: 50%; - background-color: $white; - box-shadow: 0 2px 4px 0 rgba($black, .24); - margin-right: 12px; - flex: 0 0 auto; - } - - &__token-message { - position: absolute; - color: $caribbean-green; - font-size: 11px; - bottom: 0; - left: 85px; - } - - &__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; - line-height: 43px; - margin-right: 8px; - } - - &__symbol { - color: $scorpion; - font-size: 16px; - font-weight: 400; - line-height: 24px; - } - } - } - - &__confirmation-title { - padding: 30px 120px 12px; - - @media screen and (max-width: $break-small) { - padding: 20px 0; - width: 100%; - } - } - - &__confirmation-content { - padding-bottom: 60px; - } - - &__confirmation-token-list-item { - display: flex; - flex-flow: row nowrap; - margin: 0 auto; - align-items: center; - } - - &__confirmation-token-list-item + &__confirmation-token-list-item { - margin-top: 30px; - } - - &__confirmation-token-icon { - margin-right: 18px; - } - - @media screen and (max-width: $break-small) { - top: 0; - width: 100%; - overflow: hidden; - flex: 1 0 auto; - - &__wrapper { - box-shadow: none !important; - flex: 1 1 auto; - width: 100%; - overflow-y: scroll; - height: 400px; - } - - &__footers { - border-bottom: 1px solid $gallery; - } - - &__token-icon { - width: 50px; - height: 50px; - } - - &__token-symbol { - font-size: 18px; - line-height: 24px; - } - - &__token-name { - font-size: 12px; - line-height: 16px; - } - - &__buttons { - padding: 1rem; - margin: 0; - border-top: 1px solid $gallery; - width: 100%; - } - } -} diff --git a/ui/app/css/itcss/components/buttons.scss b/ui/app/css/itcss/components/buttons.scss index 86daf60d8..4cbed6093 100644 --- a/ui/app/css/itcss/components/buttons.scss +++ b/ui/app/css/itcss/components/buttons.scss @@ -15,8 +15,9 @@ font-size: 14px; font-weight: 500; transition: border-color .3s ease; - padding: 0 20px; + padding: 0 16px; min-width: 140px; + width: 100%; text-transform: uppercase; outline: none; } @@ -110,6 +111,7 @@ font-size: .85rem; font-weight: 400; transition: border-color .3s ease; + width: 100%; &:hover { border-color: $scorpion; @@ -126,6 +128,7 @@ font-size: .85rem; font-weight: 400; transition: border-color .3s ease; + width: 100%; } // No longer used in flat design, remove when modal buttons done diff --git a/ui/app/css/itcss/components/index.scss b/ui/app/css/itcss/components/index.scss index 1c544e162..1d87b8004 100644 --- a/ui/app/css/itcss/components/index.scss +++ b/ui/app/css/itcss/components/index.scss @@ -30,8 +30,6 @@ @import './token-list.scss'; -@import './add-token.scss'; - @import './currency-display.scss'; @import './account-menu.scss'; @@ -62,4 +60,4 @@ @import './sender-to-recipient.scss'; -@import '../../../components/export-text-container/export-text-container.scss'; +@import '../../../components/index'; diff --git a/ui/app/css/itcss/components/newui-sections.scss b/ui/app/css/itcss/components/newui-sections.scss index 2903e07b4..bbe0ee661 100644 --- a/ui/app/css/itcss/components/newui-sections.scss +++ b/ui/app/css/itcss/components/newui-sections.scss @@ -144,8 +144,8 @@ $wallet-view-bg: $alabaster; flex: 0 0 auto; margin: 36px auto; background: none; - padding: .7rem 2rem; transition: border-color .3s ease; + width: 150px; &:hover { border-color: $curious-blue; diff --git a/ui/app/css/itcss/components/pages/index.scss b/ui/app/css/itcss/components/pages/index.scss index 195185fff..709f8baf6 100644 --- a/ui/app/css/itcss/components/pages/index.scss +++ b/ui/app/css/itcss/components/pages/index.scss @@ -1,3 +1 @@ @import './reveal-seed.scss'; - -@import '../../../../components/pages/unlock-page/unlock-page.scss'; diff --git a/ui/app/css/itcss/generic/index.scss b/ui/app/css/itcss/generic/index.scss index 9b2982096..f667eeda8 100644 --- a/ui/app/css/itcss/generic/index.scss +++ b/ui/app/css/itcss/generic/index.scss @@ -74,28 +74,32 @@ input.large-input { } .page-container { - width: 400px; + width: 408px; background-color: $white; box-shadow: 0 0 7px 0 rgba(0, 0, 0, .08); z-index: 25; display: flex; flex-flow: column; - border-radius: 7px; + border-radius: 8px; &__header { display: flex; flex-flow: column; border-bottom: 1px solid $geyser; - padding: 20px; + padding: 16px; flex: 0 0 auto; position: relative; + + &--no-padding-bottom { + padding-bottom: 0; + } } &__header-close { color: $tundora; position: absolute; - top: 20px; - right: 20px; + top: 16px; + right: 16px; cursor: pointer; overflow: hidden; @@ -117,7 +121,7 @@ input.large-input { flex-flow: row; justify-content: center; border-top: 1px solid $geyser; - padding: 1.6rem; + padding: 16px; flex: 0 0 auto; .btn-clear, @@ -128,11 +132,10 @@ input.large-input { } &__footer-button { - width: 165px; height: 55px; font-size: 1rem; text-transform: uppercase; - margin-right: 1.2rem; + margin-right: 16px; border-radius: 2px; &:last-of-type { @@ -162,25 +165,20 @@ input.large-input { } &__tabs { - padding: 0 1.3rem; display: flex; + margin-top: 16px; } &__tab { min-width: 5rem; - padding: .2rem .8rem .9rem; + padding: 8px; color: $dusty-gray; font-family: Roboto; - font-size: 1.1rem; - line-height: initial; + font-size: 1rem; text-align: center; cursor: pointer; border-bottom: none; - margin-right: 1rem; - - &:hover { - color: $black; - } + margin-right: 16px; &:last-of-type { margin-right: 0; @@ -189,10 +187,6 @@ input.large-input { &--selected { color: $curious-blue; border-bottom: 3px solid $curious-blue; - - &:hover { - color: $curious-blue; - } } } @@ -260,7 +254,8 @@ input.large-input { @media screen and (min-width: 576px) { .page-container { - height: 600px; + max-height: 82vh; + min-height: 570px; flex: 0 0 auto; } } @@ -303,3 +298,9 @@ input.form-control { border: 1px solid $monzo; } } + +.hide-text-overflow { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/ui/app/helpers/with-token-tracker.js b/ui/app/helpers/with-token-tracker.js new file mode 100644 index 000000000..e24517c18 --- /dev/null +++ b/ui/app/helpers/with-token-tracker.js @@ -0,0 +1,105 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import TokenTracker from 'eth-token-tracker' + +const withTokenTracker = WrappedComponent => { + return class TokenTrackerWrappedComponent extends Component { + static propTypes = { + userAddress: PropTypes.string.isRequired, + token: PropTypes.object.isRequired, + } + + constructor (props) { + super(props) + + this.state = { + string: '', + symbol: '', + error: null, + } + + this.tracker = null + this.updateBalance = this.updateBalance.bind(this) + this.setError = this.setError.bind(this) + } + + componentDidMount () { + this.createFreshTokenTracker() + } + + componentDidUpdate (prevProps) { + const { userAddress: newAddress, token: { address: newTokenAddress } } = this.props + const { userAddress: oldAddress, token: { address: oldTokenAddress } } = prevProps + + if ((oldAddress === newAddress) && (oldTokenAddress === newTokenAddress)) { + return + } + + if ((!oldAddress || !newAddress) && (!oldTokenAddress || !newTokenAddress)) { + return + } + + this.createFreshTokenTracker() + } + + componentWillUnmount () { + this.removeListeners() + } + + createFreshTokenTracker () { + this.removeListeners() + + if (!global.ethereumProvider) { + return + } + + const { userAddress, token } = this.props + + this.tracker = new TokenTracker({ + userAddress, + provider: global.ethereumProvider, + tokens: [token], + pollingInterval: 8000, + }) + + this.tracker.on('update', this.updateBalance) + this.tracker.on('error', this.setError) + + this.tracker.updateBalances() + .then(() => this.updateBalance(this.tracker.serialize())) + .catch(error => this.setState({ error: error.message })) + } + + setError (error) { + this.setState({ error }) + } + + updateBalance (tokens = []) { + const [{ string, symbol }] = tokens + this.setState({ string, symbol, error: null }) + } + + removeListeners () { + if (this.tracker) { + this.tracker.stop() + this.tracker.removeListener('update', this.updateBalance) + this.tracker.removeListener('error', this.setError) + } + } + + render () { + const { string, symbol, error } = this.state + + return ( + + ) + } + } +} + +module.exports = withTokenTracker diff --git a/ui/app/reducers/metamask.js b/ui/app/reducers/metamask.js index bb35cf990..69fe89735 100644 --- a/ui/app/reducers/metamask.js +++ b/ui/app/reducers/metamask.js @@ -28,6 +28,7 @@ function reduceMetamask (state, action) { contractExchangeRates: {}, tokenExchangeRates: {}, tokens: [], + pendingTokens: {}, send: { gasLimit: null, gasPrice: null, @@ -356,6 +357,17 @@ function reduceMetamask (state, action) { currentLocale: action.value, }) + case actions.SET_PENDING_TOKENS: + return extend(metamaskState, { + pendingTokens: { ...action.payload }, + }) + + case actions.CLEAR_PENDING_TOKENS: { + return extend(metamaskState, { + pendingTokens: {}, + }) + } + default: return metamaskState diff --git a/ui/app/routes.js b/ui/app/routes.js index 4b3f8f4d8..0ff3f644d 100644 --- a/ui/app/routes.js +++ b/ui/app/routes.js @@ -6,6 +6,7 @@ const REVEAL_SEED_ROUTE = '/seed' 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 NEW_ACCOUNT_ROUTE = '/new-account' const IMPORT_ACCOUNT_ROUTE = '/new-account/import' const SEND_ROUTE = '/send' @@ -31,6 +32,7 @@ module.exports = { CONFIRM_SEED_ROUTE, RESTORE_VAULT_ROUTE, ADD_TOKEN_ROUTE, + CONFIRM_ADD_TOKEN_ROUTE, NEW_ACCOUNT_ROUTE, IMPORT_ACCOUNT_ROUTE, SEND_ROUTE, From c4e75a7075a2a7d4726475a37d11acb4b1eec5fa Mon Sep 17 00:00:00 2001 From: Alexander Tseung Date: Sun, 20 May 2018 14:08:45 -0700 Subject: [PATCH 2/3] Fix tests --- test/base.conf.js | 3 + test/integration/lib/add-token.js | 71 +++++++++++-------- .../pages/add-token/add-token.component.js | 17 ++--- 3 files changed, 50 insertions(+), 41 deletions(-) diff --git a/test/base.conf.js b/test/base.conf.js index e2e9d44ba..956dce011 100644 --- a/test/base.conf.js +++ b/test/base.conf.js @@ -6,6 +6,9 @@ module.exports = function(config) { // base path that will be used to resolve all patterns (eg. files, exclude) basePath: process.cwd(), + // Uncomment to allow for longer timeouts + // browserNoActivityTimeout: 100000000, + browserConsoleLogOptions: { terminal: false, }, diff --git a/test/integration/lib/add-token.js b/test/integration/lib/add-token.js index 1840bdd39..e51c854d2 100644 --- a/test/integration/lib/add-token.js +++ b/test/integration/lib/add-token.js @@ -22,6 +22,11 @@ async function runAddTokenFlowTest (assert, done) { selectState.val('add token') reactTriggerChange(selectState[0]) + // Used to set values on TextField input component + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, 'value' + ).set + // Check that no tokens have been added assert.ok($('.token-list-item').length === 0, 'no tokens added') @@ -31,14 +36,14 @@ async function runAddTokenFlowTest (assert, done) { addTokenButton[0].click() // Verify Add Token screen - let addTokenWrapper = await queryAsync($, '.add-token__wrapper') + let addTokenWrapper = await queryAsync($, '.page-container') assert.ok(addTokenWrapper[0], 'add token wrapper renders') - let addTokenTitle = await queryAsync($, '.add-token__header__title') + let addTokenTitle = await queryAsync($, '.page-container__title') assert.equal(addTokenTitle[0].textContent, 'Add Tokens', 'add token title is correct') // Cancel Add Token - const cancelAddTokenButton = await queryAsync($, 'button.btn-secondary--lg.add-token__cancel-button') + const cancelAddTokenButton = await queryAsync($, 'button.btn-secondary--lg.page-container__footer-button') assert.ok(cancelAddTokenButton[0], 'cancel add token button present') cancelAddTokenButton.click() @@ -50,20 +55,22 @@ async function runAddTokenFlowTest (assert, done) { addTokenButton[0].click() // Verify Add Token Screen - addTokenWrapper = await queryAsync($, '.add-token__wrapper') - addTokenTitle = await queryAsync($, '.add-token__header__title') + addTokenWrapper = await queryAsync($, '.page-container') + addTokenTitle = await queryAsync($, '.page-container__title') assert.ok(addTokenWrapper[0], 'add token wrapper renders') assert.equal(addTokenTitle[0].textContent, 'Add Tokens', 'add token title is correct') // Search for token - const searchInput = await queryAsync($, 'input.add-token__input') - searchInput.val('a') - reactTriggerChange(searchInput[0]) + const searchInput = (await findAsync(addTokenWrapper, '#search-tokens'))[0] + searchInput.focus() + await timeout(1000) + nativeInputValueSetter.call(searchInput, 'a') + searchInput.dispatchEvent(new Event('input', { bubbles: true})) // Click token to add - const tokenWrapper = await queryAsync($, 'div.add-token__token-wrapper') + const tokenWrapper = await queryAsync($, 'div.token-list__token') assert.ok(tokenWrapper[0], 'token found') - const tokenImageProp = tokenWrapper.find('.add-token__token-icon').css('background-image') + const tokenImageProp = tokenWrapper.find('.token-list__token-icon').css('background-image') const tokenImageUrl = tokenImageProp.slice(5, -2) tokenWrapper[0].click() @@ -73,11 +80,8 @@ async function runAddTokenFlowTest (assert, done) { nextButton[0].click() // Confirm Add token - assert.equal( - $('.add-token__description')[0].textContent, - 'Token balance(s)', - 'confirm add token rendered' - ) + const confirmAddToken = await queryAsync($, '.confirm-add-token') + assert.ok(confirmAddToken[0], 'confirm add token rendered') assert.ok($('button.btn-primary--lg')[0], 'confirm add token button found') $('button.btn-primary--lg')[0].click() @@ -91,39 +95,46 @@ async function runAddTokenFlowTest (assert, done) { assert.ok(addTokenButton[0], 'add token button present') addTokenButton[0].click() - const addTokenTabs = await queryAsync($, '.add-token__header__tabs__tab') + addTokenWrapper = await queryAsync($, '.page-container') + const addTokenTabs = await queryAsync($, '.page-container__tab') assert.equal(addTokenTabs.length, 2, 'expected number of tabs') assert.equal(addTokenTabs[1].textContent, 'Custom Token', 'Custom Token tab present') assert.ok(addTokenTabs[1], 'add custom token tab present') addTokenTabs[1].click() + await timeout(1000) // Input token contract address - const customInput = await queryAsync($, 'input.add-token__add-custom-input') - customInput.val('0x177af043D3A1Aed7cc5f2397C70248Fc6cDC056c') - reactTriggerChange(customInput[0]) + const customInput = (await findAsync(addTokenWrapper, '#custom-address'))[0] + customInput.focus() + await timeout(1000) + nativeInputValueSetter.call(customInput, '0x177af043D3A1Aed7cc5f2397C70248Fc6cDC056c') + customInput.dispatchEvent(new Event('input', { bubbles: true})) + // Click Next button - nextButton = await queryAsync($, 'button.btn-primary--lg') - assert.equal(nextButton[0].textContent, 'Next', 'next button rendered') - nextButton[0].click() + // nextButton = await queryAsync($, 'button.btn-primary--lg') + // assert.equal(nextButton[0].textContent, 'Next', 'next button rendered') + // nextButton[0].click() - // Verify symbol length error since contract address won't return symbol - const errorMessage = await queryAsync($, '.add-token__add-custom-error-message') + // // Verify symbol length error since contract address won't return symbol + const errorMessage = await queryAsync($, '#custom-symbol-helper-text') assert.ok(errorMessage[0], 'error rendered') $('button.btn-secondary--lg')[0].click() - // // Confirm Add token + // await timeout(100000) + + // Confirm Add token // assert.equal( - // $('.add-token__description')[0].textContent, + // $('.page-container__subtitle')[0].textContent, // 'Would you like to add these tokens?', // 'confirm add token rendered' // ) // assert.ok($('button.btn-primary--lg')[0], 'confirm add token button found') // $('button.btn-primary--lg')[0].click() - // // Verify added token image - // heroBalance = await queryAsync($, '.hero-balance') - // assert.ok(heroBalance, 'rendered hero balance') - // assert.ok(heroBalance.find('.identicon')[0], 'token added') + // Verify added token image + heroBalance = await queryAsync($, '.hero-balance') + assert.ok(heroBalance, 'rendered hero balance') + assert.ok(heroBalance.find('.identicon')[0], 'token added') } 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 885c7b2ac..0677b4317 100644 --- a/ui/app/components/pages/add-token/add-token.component.js +++ b/ui/app/components/pages/add-token/add-token.component.js @@ -139,17 +139,12 @@ class AddToken extends Component { } async attemptToAutoFillTokenParams (address) { - const { symbol, decimals } = await this.tokenInfoGetter(address) - - if (symbol && decimals) { - this.setState({ - customSymbol: symbol, - customDecimals: decimals, - customSymbolError: null, - customDecimalsError: null, - autoFilled: true, - }) - } + const { symbol = '', decimals = 0 } = await this.tokenInfoGetter(address) + + const autoFilled = Boolean(symbol && decimals) + this.setState({ autoFilled }) + this.handleCustomSymbolChange(symbol || '') + this.handleCustomDecimalsChange(decimals) } handleCustomAddressChange (value) { From 8245bf010e22dda3e00c044429e3f5b880691fcb Mon Sep 17 00:00:00 2001 From: Alexander Tseung Date: Mon, 21 May 2018 15:00:13 -0700 Subject: [PATCH 3/3] Update input field border on focus --- ui/app/components/text-field/text-field.component.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/app/components/text-field/text-field.component.js b/ui/app/components/text-field/text-field.component.js index 4107068b5..b695a449a 100644 --- a/ui/app/components/text-field/text-field.component.js +++ b/ui/app/components/text-field/text-field.component.js @@ -31,6 +31,7 @@ const styles = { }, }, formLabelFocused: {}, + inputFocused: {}, inputRoot: { 'label + &': { marginTop: '8px', @@ -41,7 +42,7 @@ const styles = { padding: '0 16px', display: 'flex', alignItems: 'center', - '&:focus': { + '&$inputFocused': { border: '1px solid #2f9ae0', }, }, @@ -89,6 +90,7 @@ class TextField extends Component { root: material ? '' : classes.inputRoot, input: material ? '' : classes.input, underline: material ? classes.materialUnderline : '', + focused: material ? '' : classes.inputFocused, }, }} {...textFieldProps}