From 2cd242252f0f65c72d1354790a3ba34cc55aef71 Mon Sep 17 00:00:00 2001 From: VSaric <92527393+VSaric@users.noreply.github.com> Date: Wed, 16 Feb 2022 17:59:39 +0100 Subject: [PATCH] Created "Token details" page (#13216) * Created new screen/page "Token details" * Change color in scss * Modify elements to the latest requirements and added unit tests * Review requested changes * Condensing files into one component * Added unit tests for token details page * Added redirection when switching networks, added image for a token and update unit tests * Requested review changes * Modify index.scss regarding of the requested review * Delete data-testid's from Typography and token-details-page.js * Requested review changes --- app/_locales/en/messages.json | 12 + .../hide-token-confirmation-modal.js | 10 +- ui/helpers/constants/routes.js | 3 + ui/pages/asset/components/asset-options.js | 14 ++ ui/pages/asset/components/token-asset.js | 20 +- ui/pages/pages.scss | 1 + ui/pages/routes/routes.component.js | 7 + ui/pages/token-details/index.js | 1 + ui/pages/token-details/index.scss | 49 ++++ ui/pages/token-details/token-details-page.js | 200 +++++++++++++++++ .../token-details/token-details-page.test.js | 211 ++++++++++++++++++ 11 files changed, 524 insertions(+), 4 deletions(-) create mode 100644 ui/pages/token-details/index.js create mode 100644 ui/pages/token-details/index.scss create mode 100644 ui/pages/token-details/token-details-page.js create mode 100644 ui/pages/token-details/token-details-page.test.js diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index a94462cde..90e2e6ac0 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1360,6 +1360,9 @@ "hide": { "message": "Hide" }, + "hideToken": { + "message": "Hide token" + }, "hideTokenPrompt": { "message": "Hide Token?" }, @@ -1833,6 +1836,9 @@ "negativeETH": { "message": "Can not send negative amounts of ETH." }, + "network": { + "message": "Network:" + }, "networkDetails": { "message": "Network Details" }, @@ -3275,6 +3281,12 @@ "tokenDecimalFetchFailed": { "message": "Token decimal required." }, + "tokenDecimalTitle": { + "message": "Token Decimal:" + }, + "tokenDetails": { + "message": "Token details" + }, "tokenDetectionAnnouncement": { "message": "New! Improved token detection is available on Ethereum Mainnet as an experimental feature. $1" }, diff --git a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js index 2c0965ebf..480d1610b 100644 --- a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js +++ b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js @@ -4,10 +4,12 @@ import { connect } from 'react-redux'; import * as actions from '../../../../store/actions'; import Identicon from '../../../ui/identicon'; import Button from '../../../ui/button'; +import { DEFAULT_ROUTE } from '../../../../helpers/constants/routes'; function mapStateToProps(state) { return { token: state.appState.modal.modalState.props.token, + history: state.appState.modal.modalState.props.history, }; } @@ -35,12 +37,13 @@ class HideTokenConfirmationModal extends Component { address: PropTypes.string, image: PropTypes.string, }), + history: PropTypes.object, }; state = {}; render() { - const { token, hideToken, hideModal } = this.props; + const { token, hideToken, hideModal, history } = this.props; const { symbol, address, image } = token; return ( @@ -72,7 +75,10 @@ class HideTokenConfirmationModal extends Component { type="primary" className="hide-token-confirmation__button" data-testid="hide-token-confirmation__hide" - onClick={() => hideToken(address)} + onClick={() => { + hideToken(address); + history.push(DEFAULT_ROUTE); + }} > {this.context.t('hide')} diff --git a/ui/helpers/constants/routes.js b/ui/helpers/constants/routes.js index 1969ff49b..6ddc4ba26 100644 --- a/ui/helpers/constants/routes.js +++ b/ui/helpers/constants/routes.js @@ -28,6 +28,7 @@ const NEW_ACCOUNT_ROUTE = '/new-account'; const IMPORT_ACCOUNT_ROUTE = '/new-account/import'; const CONNECT_HARDWARE_ROUTE = '/new-account/connect'; const SEND_ROUTE = '/send'; +const TOKEN_DETAILS = '/token-details'; const CONNECT_ROUTE = '/connect'; const CONNECT_CONFIRM_PERMISSIONS_ROUTE = '/confirm-permissions'; ///: BEGIN:ONLY_INCLUDE_IN(flask) @@ -123,6 +124,7 @@ const PATH_NAME_MAP = { [IMPORT_ACCOUNT_ROUTE]: 'Import Account Page', [CONNECT_HARDWARE_ROUTE]: 'Connect Hardware Wallet Page', [SEND_ROUTE]: 'Send Page', + [TOKEN_DETAILS]: 'Token Details Page', [`${CONNECT_ROUTE}/:id`]: 'Connect To Site Confirmation Page', [`${CONNECT_ROUTE}/:id${CONNECT_CONFIRM_PERMISSIONS_ROUTE}`]: 'Grant Connected Site Permissions Confirmation Page', [CONNECTED_ROUTE]: 'Sites Connected To This Account Page', @@ -181,6 +183,7 @@ export { IMPORT_ACCOUNT_ROUTE, CONNECT_HARDWARE_ROUTE, SEND_ROUTE, + TOKEN_DETAILS, INITIALIZE_ROUTE, INITIALIZE_WELCOME_ROUTE, INITIALIZE_UNLOCK_ROUTE, diff --git a/ui/pages/asset/components/asset-options.js b/ui/pages/asset/components/asset-options.js index 08ec391ab..160c77883 100644 --- a/ui/pages/asset/components/asset-options.js +++ b/ui/pages/asset/components/asset-options.js @@ -8,6 +8,7 @@ const AssetOptions = ({ onRemove, onClickBlockExplorer, onViewAccountDetails, + onViewTokenDetails, tokenSymbol, isNativeAsset, isEthNetwork, @@ -66,6 +67,18 @@ const AssetOptions = ({ {t('hideTokenSymbol', [tokenSymbol])} )} + {isNativeAsset ? null : ( + { + setAssetOptionsOpen(false); + onViewTokenDetails(); + }} + > + {t('tokenDetails')} + + )} ) : null} @@ -78,6 +91,7 @@ AssetOptions.propTypes = { onRemove: PropTypes.func.isRequired, onClickBlockExplorer: PropTypes.func.isRequired, onViewAccountDetails: PropTypes.func.isRequired, + onViewTokenDetails: PropTypes.func.isRequired, tokenSymbol: PropTypes.string, }; diff --git a/ui/pages/asset/components/token-asset.js b/ui/pages/asset/components/token-asset.js index e48d0168f..5fef2a741 100644 --- a/ui/pages/asset/components/token-asset.js +++ b/ui/pages/asset/components/token-asset.js @@ -10,10 +10,14 @@ import { getSelectedIdentity, getRpcPrefsForCurrentProvider, } from '../../../selectors/selectors'; -import { DEFAULT_ROUTE } from '../../../helpers/constants/routes'; +import { + DEFAULT_ROUTE, + TOKEN_DETAILS, +} from '../../../helpers/constants/routes'; import { getURLHostName } from '../../../helpers/utils/util'; import { showModal } from '../../../store/actions'; import { useNewMetricEvent } from '../../../hooks/useMetricEvent'; +import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send'; import AssetNavigation from './asset-navigation'; import AssetOptions from './asset-options'; @@ -53,7 +57,9 @@ export default function TokenAsset({ token }) { optionsButton={ - dispatch(showModal({ name: 'HIDE_TOKEN_CONFIRMATION', token })) + dispatch( + showModal({ name: 'HIDE_TOKEN_CONFIRMATION', token, history }), + ) } isEthNetwork={!rpcPrefs.blockExplorerUrl} onClickBlockExplorer={() => { @@ -63,6 +69,16 @@ export default function TokenAsset({ token }) { onViewAccountDetails={() => { dispatch(showModal({ name: 'ACCOUNT_DETAILS' })); }} + onViewTokenDetails={() => { + dispatch( + updateSendAsset({ + type: ASSET_TYPES.TOKEN, + details: { ...token }, + }), + ).then(() => { + history.push(TOKEN_DETAILS); + }); + }} tokenSymbol={token.symbol} /> } diff --git a/ui/pages/pages.scss b/ui/pages/pages.scss index 175f9145a..501400efc 100644 --- a/ui/pages/pages.scss +++ b/ui/pages/pages.scss @@ -20,5 +20,6 @@ @import 'send/send'; @import 'settings/index'; @import 'swaps/index'; +@import 'token-details/index'; @import 'unlock-page/index'; @import 'onboarding-flow/index'; diff --git a/ui/pages/routes/routes.component.js b/ui/pages/routes/routes.component.js index f83efe74d..94c9b93f9 100644 --- a/ui/pages/routes/routes.component.js +++ b/ui/pages/routes/routes.component.js @@ -33,6 +33,7 @@ import UnlockPage from '../unlock-page'; import Alerts from '../../components/app/alerts'; import Asset from '../asset'; import OnboardingAppHeader from '../onboarding-flow/onboarding-app-header/onboarding-app-header'; +import TokenDetailsPage from '../token-details'; import { IMPORT_TOKEN_ROUTE, @@ -57,6 +58,7 @@ import { INITIALIZE_ROUTE, ONBOARDING_ROUTE, ADD_COLLECTIBLE_ROUTE, + TOKEN_DETAILS, } from '../../helpers/constants/routes'; import { @@ -152,6 +154,11 @@ export default class Routes extends Component { component={SendTransactionScreen} exact /> + ({ + asset: getSendAssetAddress(state), + })); + + const { asset: tokenAddress } = assetAddress; + + const tokenMetadata = tokenList[tokenAddress]; + const fileName = tokenMetadata?.iconUrl; + const imagePath = useTokenDetection + ? fileName + : `images/contract/${fileName}`; + + const token = tokens.find(({ address }) => + isEqualCaseInsensitive(address, tokenAddress), + ); + + const { tokensWithBalances } = useTokenTracker([token]); + const tokenBalance = tokensWithBalances[0]?.string; + const tokenCurrencyBalance = useTokenFiatAmount( + token?.address, + tokenBalance, + token?.symbol, + ); + + const currentNetwork = useSelector((state) => ({ + nickname: state.metamask.provider.nickname, + type: state.metamask.provider.type, + })); + + const { nickname: networkNickname, type: networkType } = currentNetwork; + + const [copied, handleCopy] = useCopyToClipboard(); + + if (!token) { + return ; + } + return ( + + + + {t('tokenDetails')} + + + + + {t('tokenDecimalTitle')} + + + {token.decimals} + + + {t('network')} + + + {networkType === NETWORK_TYPE_RPC + ? networkNickname ?? t('privateNetwork') + : t(networkType)} + + + + + ); +} diff --git a/ui/pages/token-details/token-details-page.test.js b/ui/pages/token-details/token-details-page.test.js new file mode 100644 index 000000000..ae5a6a47e --- /dev/null +++ b/ui/pages/token-details/token-details-page.test.js @@ -0,0 +1,211 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { fireEvent } from '@testing-library/react'; +import { renderWithProvider } from '../../../test/lib/render-helpers'; +import Identicon from '../../components/ui/identicon/identicon.component'; +import TokenDetailsPage from './token-details-page'; + +const state = { + metamask: { + selectedAddress: '0xAddress', + contractExchangeRates: { + '0xAnotherToken': 0.015, + }, + useTokenDetection: true, + tokenList: { + '0x6b175474e89094c44da98b954eedeac495271d0f': { + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + symbol: 'META', + decimals: 18, + image: 'metamark.svg', + unlisted: false, + }, + '0xB8c77482e45F1F44dE1745F52C74426C631bDD52': { + address: '0xB8c77482e45F1F44dE1745F52C74426C631bDD52', + symbol: '0X', + decimals: 18, + image: '0x.svg', + unlisted: false, + }, + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + symbol: 'AST', + decimals: 18, + image: 'ast.png', + unlisted: false, + }, + '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2': { + address: '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2', + symbol: 'BAT', + decimals: 18, + image: 'BAT_icon.svg', + unlisted: false, + }, + '0xe83cccfabd4ed148903bf36d4283ee7c8b3494d1': { + address: '0xe83cccfabd4ed148903bf36d4283ee7c8b3494d1', + symbol: 'CVL', + decimals: 18, + image: 'CVL_token.svg', + unlisted: false, + }, + '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e': { + address: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', + symbol: 'GLA', + decimals: 18, + image: 'gladius.svg', + unlisted: false, + }, + '0x467Bccd9d29f223BcE8043b84E8C8B282827790F': { + address: '0x467Bccd9d29f223BcE8043b84E8C8B282827790F', + symbol: 'GNO', + decimals: 18, + image: 'gnosis.svg', + unlisted: false, + }, + '0xff20817765cb7f73d4bde2e66e067e58d11095c2': { + address: '0xff20817765cb7f73d4bde2e66e067e58d11095c2', + symbol: 'OMG', + decimals: 18, + image: 'omg.jpg', + unlisted: false, + }, + '0x8e870d67f660d95d5be530380d0ec0bd388289e1': { + address: '0x8e870d67f660d95d5be530380d0ec0bd388289e1', + symbol: 'WED', + decimals: 18, + image: 'wed.png', + unlisted: false, + }, + }, + provider: { + type: 'mainnet', + nickname: '', + }, + preferences: { + showFiatInTestnets: true, + }, + tokens: [ + { + address: '0xaD6D458402F60fD3Bd25163575031ACDce07538A', + symbol: 'DAA', + decimals: 18, + image: null, + isERC721: false, + }, + { + address: '0xaD6D458402F60fD3Bd25163575031ACDce07538U', + symbol: 'DAU', + decimals: 18, + image: null, + isERC721: false, + }, + ], + }, + send: { + asset: { + balance: '0x0', + type: 'TOKEN', + details: { + address: '0xaD6D458402F60fD3Bd25163575031ACDce07538A', + decimals: 18, + image: null, + isERC721: false, + symbol: 'DAI', + }, + }, + }, + token: { + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + image: './images/eth_logo.svg', + isERC721: false, + symbol: 'ETH', + }, +}; + +describe('TokenDetailsPage', () => { + it('should render title "Token details" in token details page', () => { + const store = configureMockStore()(state); + const { getByText } = renderWithProvider(, store); + expect(getByText('Token details')).toBeInTheDocument(); + }); + + it('should close token details page when close button is clicked', () => { + const store = configureMockStore()(state); + const { container } = renderWithProvider(, store); + const onCloseBtn = container.querySelector('.token-details__closeButton'); + fireEvent.click(onCloseBtn); + expect(onCloseBtn).toBeDefined(); + }); + + it('should render an icon image', () => { + const image = ( + + ); + expect(image).toBeDefined(); + }); + + it('should render token contract address title in token details page', () => { + const store = configureMockStore()(state); + const { getByText } = renderWithProvider(, store); + expect(getByText('Token Contract Address')).toBeInTheDocument(); + }); + + it('should render token contract address in token details page', () => { + const store = configureMockStore()(state); + const { getByText } = renderWithProvider(, store); + expect(getByText(state.send.asset.details.address)).toBeInTheDocument(); + }); + + it('should call copy button when click is simulated', () => { + const store = configureMockStore()(state); + const { container } = renderWithProvider(, store); + const handleCopyBtn = container.querySelector('.token-details__copyIcon'); + fireEvent.click(handleCopyBtn); + expect(handleCopyBtn).toBeDefined(); + }); + + it('should render token decimal title in token details page', () => { + const store = configureMockStore()(state); + const { getByText } = renderWithProvider(, store); + expect(getByText('Token Decimal:')).toBeInTheDocument(); + }); + + it('should render number of token decimals in token details page', () => { + const store = configureMockStore()(state); + const { getByText } = renderWithProvider(, store); + expect(getByText('18')).toBeInTheDocument(); + }); + + it('should render current network title in token details page', () => { + const store = configureMockStore()(state); + const { getByText } = renderWithProvider(, store); + expect(getByText('Network:')).toBeInTheDocument(); + }); + + it('should render current network in token details page', () => { + const store = configureMockStore()(state); + const { getByText } = renderWithProvider(, store); + expect(getByText('Ethereum Mainnet')).toBeInTheDocument(); + }); + + it('should call hide token button when button is clicked in token details page', () => { + const store = configureMockStore()(state); + const { container } = renderWithProvider(, store); + const hideTokenBtn = container.querySelector( + '.token-details__hide-token-button', + ); + fireEvent.click(hideTokenBtn); + expect(hideTokenBtn).toBeDefined(); + }); + + it('should render label of hide token button in token details page', () => { + const store = configureMockStore()(state); + const { getByText } = renderWithProvider(, store); + expect(getByText('Hide token')).toBeInTheDocument(); + }); +});