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
feature/default_network_editable
VSaric 3 years ago committed by GitHub
parent 3735a601d9
commit 2cd242252f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 12
      app/_locales/en/messages.json
  2. 10
      ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js
  3. 3
      ui/helpers/constants/routes.js
  4. 14
      ui/pages/asset/components/asset-options.js
  5. 20
      ui/pages/asset/components/token-asset.js
  6. 1
      ui/pages/pages.scss
  7. 7
      ui/pages/routes/routes.component.js
  8. 1
      ui/pages/token-details/index.js
  9. 49
      ui/pages/token-details/index.scss
  10. 200
      ui/pages/token-details/token-details-page.js
  11. 211
      ui/pages/token-details/token-details-page.test.js

@ -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"
},

@ -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')}
</Button>

@ -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,

@ -8,6 +8,7 @@ const AssetOptions = ({
onRemove,
onClickBlockExplorer,
onViewAccountDetails,
onViewTokenDetails,
tokenSymbol,
isNativeAsset,
isEthNetwork,
@ -66,6 +67,18 @@ const AssetOptions = ({
{t('hideTokenSymbol', [tokenSymbol])}
</MenuItem>
)}
{isNativeAsset ? null : (
<MenuItem
iconClassName="fas fa-info-circle asset-options__icon"
data-testid="asset-options__token-details"
onClick={() => {
setAssetOptionsOpen(false);
onViewTokenDetails();
}}
>
{t('tokenDetails')}
</MenuItem>
)}
</Menu>
) : 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,
};

@ -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={
<AssetOptions
onRemove={() =>
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}
/>
}

@ -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';

@ -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
/>
<Authenticated
path={TOKEN_DETAILS}
component={TokenDetailsPage}
exact
/>
<Authenticated path={SWAPS_ROUTE} component={Swaps} />
<Authenticated
path={IMPORT_TOKEN_ROUTE}

@ -0,0 +1 @@
export { default } from './token-details-page';

@ -0,0 +1,49 @@
.token-details {
&__title {
text-transform: capitalize;
}
&__closeButton {
float: right;
width: 10px;
margin-top: -17px;
margin-inline-end: -8px;
&::after {
font-size: 24px;
content: '\00D7';
color: var(--black);
}
}
&__token-value {
font-size: 32px;
}
&__token-address {
width: 222px;
}
&__copy-icon {
float: right;
margin-inline-start: 62px;
@media screen and (min-width: $break-large) {
margin-inline-start: 112px;
}
}
&__hide-token-button {
width: 319px;
height: 39px;
margin-top: 70px;
@media screen and (min-width: $break-large) {
margin-inline-start: 20px;
}
}
.btn--rounded.btn-primary {
background-color: #fff;
}
}

@ -0,0 +1,200 @@
import React, { useContext } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Redirect, useHistory } from 'react-router-dom';
import { getTokens } from '../../ducks/metamask/metamask';
import { getSendAssetAddress } from '../../ducks/send';
import { getUseTokenDetection, getTokenList } from '../../selectors';
import { useCopyToClipboard } from '../../hooks/useCopyToClipboard';
import { isEqualCaseInsensitive } from '../../helpers/utils/util';
import Identicon from '../../components/ui/identicon/identicon.component';
import { I18nContext } from '../../contexts/i18n';
import { useTokenTracker } from '../../hooks/useTokenTracker';
import { useTokenFiatAmount } from '../../hooks/useTokenFiatAmount';
import { showModal } from '../../store/actions';
import { NETWORK_TYPE_RPC } from '../../../shared/constants/network';
import { ASSET_ROUTE, DEFAULT_ROUTE } from '../../helpers/constants/routes';
import Tooltip from '../../components/ui/tooltip';
import Button from '../../components/ui/button';
import CopyIcon from '../../components/ui/icon/copy-icon.component';
import Box from '../../components/ui/box';
import Typography from '../../components/ui/typography';
import {
COLORS,
TYPOGRAPHY,
FONT_WEIGHT,
DISPLAY,
TEXT_ALIGN,
OVERFLOW_WRAP,
} from '../../helpers/constants/design-system';
export default function TokenDetailsPage() {
const dispatch = useDispatch();
const history = useHistory();
const t = useContext(I18nContext);
const tokens = useSelector(getTokens);
const tokenList = useSelector(getTokenList);
const useTokenDetection = useSelector(getUseTokenDetection);
const assetAddress = useSelector((state) => ({
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 <Redirect to={{ pathname: DEFAULT_ROUTE }} />;
}
return (
<Box className="page-container token-details">
<Box marginLeft={5} marginRight={6}>
<Typography
fontWeight={FONT_WEIGHT.BOLD}
margin={[4, 0, 0, 0]}
variant={TYPOGRAPHY.H6}
color={COLORS.BLACK}
className="token-details__title"
>
{t('tokenDetails')}
<Button
type="link"
onClick={() => history.push(`${ASSET_ROUTE}/${token.address}`)}
className="token-details__closeButton"
/>
</Typography>
<Box display={DISPLAY.FLEX} marginTop={4}>
<Typography
align={TEXT_ALIGN.CENTER}
fontWeight={FONT_WEIGHT.BOLD}
margin={[0, 5, 0, 0]}
variant={TYPOGRAPHY.H4}
color={COLORS.BLACK}
className="token-details__token-value"
>
{tokenBalance}
</Typography>
<Box marginTop={1}>
<Identicon
diameter={32}
address={token.address}
image={tokenMetadata ? imagePath : token.image}
/>
</Box>
</Box>
<Typography
margin={[4, 0, 0, 0]}
variant={TYPOGRAPHY.H7}
color={COLORS.UI4}
>
{tokenCurrencyBalance || ''}
</Typography>
<Typography
margin={[6, 0, 0, 0]}
variant={TYPOGRAPHY.H9}
color={COLORS.UI4}
fontWeight={FONT_WEIGHT.BOLD}
>
{t('tokenContractAddress')}
</Typography>
<Box display={DISPLAY.FLEX}>
<Typography
variant={TYPOGRAPHY.H7}
margin={[2, 0, 0, 0]}
color={COLORS.BLACK}
overflowWrap={OVERFLOW_WRAP.BREAK_WORD}
className="token-details__token-address"
>
{token.address}
</Typography>
<Tooltip
position="bottom"
title={copied ? t('copiedExclamation') : t('copyToClipboard')}
containerClassName="token-details__copy-icon"
>
<Button
type="link"
className="token-details__copyIcon"
onClick={() => {
handleCopy(token.address);
}}
>
<CopyIcon size={11} color="#037DD6" />
</Button>
</Tooltip>
</Box>
<Typography
variant={TYPOGRAPHY.H9}
margin={[4, 0, 0, 0]}
color={COLORS.UI4}
fontWeight={FONT_WEIGHT.BOLD}
>
{t('tokenDecimalTitle')}
</Typography>
<Typography
variant={TYPOGRAPHY.H7}
margin={[1, 0, 0, 0]}
color={COLORS.BLACK}
>
{token.decimals}
</Typography>
<Typography
variant={TYPOGRAPHY.H9}
margin={[4, 0, 0, 0]}
color={COLORS.UI4}
fontWeight={FONT_WEIGHT.BOLD}
>
{t('network')}
</Typography>
<Typography
variant={TYPOGRAPHY.H7}
margin={[1, 0, 0, 0]}
color={COLORS.BLACK}
>
{networkType === NETWORK_TYPE_RPC
? networkNickname ?? t('privateNetwork')
: t(networkType)}
</Typography>
<Button
type="primary"
className="token-details__hide-token-button"
onClick={() => {
dispatch(
showModal({ name: 'HIDE_TOKEN_CONFIRMATION', token, history }),
);
}}
>
<Typography variant={TYPOGRAPHY.H6} color={COLORS.PRIMARY1}>
{t('hideToken')}
</Typography>
</Button>
</Box>
</Box>
);
}

@ -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(<TokenDetailsPage />, store);
expect(getByText('Token details')).toBeInTheDocument();
});
it('should close token details page when close button is clicked', () => {
const store = configureMockStore()(state);
const { container } = renderWithProvider(<TokenDetailsPage />, store);
const onCloseBtn = container.querySelector('.token-details__closeButton');
fireEvent.click(onCloseBtn);
expect(onCloseBtn).toBeDefined();
});
it('should render an icon image', () => {
const image = (
<Identicon
diameter={32}
address={state.send.asset.details.address}
image={state.token.image}
/>
);
expect(image).toBeDefined();
});
it('should render token contract address title in token details page', () => {
const store = configureMockStore()(state);
const { getByText } = renderWithProvider(<TokenDetailsPage />, store);
expect(getByText('Token Contract Address')).toBeInTheDocument();
});
it('should render token contract address in token details page', () => {
const store = configureMockStore()(state);
const { getByText } = renderWithProvider(<TokenDetailsPage />, 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(<TokenDetailsPage />, 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(<TokenDetailsPage />, store);
expect(getByText('Token Decimal:')).toBeInTheDocument();
});
it('should render number of token decimals in token details page', () => {
const store = configureMockStore()(state);
const { getByText } = renderWithProvider(<TokenDetailsPage />, store);
expect(getByText('18')).toBeInTheDocument();
});
it('should render current network title in token details page', () => {
const store = configureMockStore()(state);
const { getByText } = renderWithProvider(<TokenDetailsPage />, store);
expect(getByText('Network:')).toBeInTheDocument();
});
it('should render current network in token details page', () => {
const store = configureMockStore()(state);
const { getByText } = renderWithProvider(<TokenDetailsPage />, 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(<TokenDetailsPage />, 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(<TokenDetailsPage />, store);
expect(getByText('Hide token')).toBeInTheDocument();
});
});
Loading…
Cancel
Save