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 : (
+
+ )}
) : 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')}
+
+
+
+ {tokenBalance}
+
+
+
+
+
+
+ {tokenCurrencyBalance || ''}
+
+
+ {t('tokenContractAddress')}
+
+
+
+ {token.address}
+
+
+
+
+
+
+ {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();
+ });
+});