diff --git a/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js b/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js deleted file mode 100644 index 5f40a6cc4..000000000 --- a/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js +++ /dev/null @@ -1,32 +0,0 @@ -import { connect } from 'react-redux'; -import { compose } from 'redux'; -import { withRouter } from 'react-router-dom'; -import { rejectWatchAsset, acceptWatchAsset } from '../../store/actions'; -import { getMostRecentOverviewPage } from '../../ducks/history/history'; -import ConfirmAddSuggestedToken from './confirm-add-suggested-token.component'; - -const mapStateToProps = (state) => { - const { - metamask: { suggestedAssets, tokens }, - } = state; - - return { - mostRecentOverviewPage: getMostRecentOverviewPage(state), - suggestedAssets, - tokens, - }; -}; - -const mapDispatchToProps = (dispatch) => { - return { - rejectWatchAsset: (suggestedAssetID) => - dispatch(rejectWatchAsset(suggestedAssetID)), - acceptWatchAsset: (suggestedAssetID) => - dispatch(acceptWatchAsset(suggestedAssetID)), - }; -}; - -export default compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps), -)(ConfirmAddSuggestedToken); diff --git a/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js b/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.js similarity index 64% rename from ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js rename to ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.js index 8e7d30ec0..cb59f95d6 100644 --- a/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js +++ b/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.js @@ -1,16 +1,21 @@ -import React, { useContext, useEffect, useMemo } from 'react'; -import PropTypes from 'prop-types'; +import React, { useCallback, useContext, useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; import ActionableMessage from '../../components/ui/actionable-message/actionable-message'; import Button from '../../components/ui/button'; import Identicon from '../../components/ui/identicon'; import TokenBalance from '../../components/ui/token-balance'; import { I18nContext } from '../../contexts/i18n'; -import { MetaMetricsContext } from '../../contexts/metametrics'; +import { MetaMetricsContext as NewMetaMetricsContext } from '../../contexts/metametrics.new'; +import { getMostRecentOverviewPage } from '../../ducks/history/history'; +import { getTokens } from '../../ducks/metamask/metamask'; import ZENDESK_URLS from '../../helpers/constants/zendesk-url'; import { isEqualCaseInsensitive } from '../../../shared/modules/string-utils'; +import { getSuggestedAssets } from '../../selectors'; +import { rejectWatchAsset, acceptWatchAsset } from '../../store/actions'; function getTokenName(name, symbol) { - return typeof name === 'undefined' ? symbol : `${name} (${symbol})`; + return name === undefined ? symbol : `${name} (${symbol})`; } /** @@ -51,32 +56,16 @@ function hasDuplicateSymbolAndDiffAddress(suggestedAssets, tokens) { return Boolean(duplicate); } -const ConfirmAddSuggestedToken = (props) => { - const { - acceptWatchAsset, - history, - mostRecentOverviewPage, - rejectWatchAsset, - suggestedAssets, - tokens, - } = props; - - const metricsEvent = useContext(MetaMetricsContext); +const ConfirmAddSuggestedToken = () => { const t = useContext(I18nContext); + const dispatch = useDispatch(); + const history = useHistory(); - const tokenAddedEvent = (asset) => { - metricsEvent({ - event: 'Token Added', - category: 'Wallet', - sensitiveProperties: { - token_symbol: asset.symbol, - token_contract_address: asset.address, - token_decimal_precision: asset.decimals, - unlisted: asset.unlisted, - source: 'dapp', - }, - }); - }; + const mostRecentOverviewPage = useSelector(getMostRecentOverviewPage); + const suggestedAssets = useSelector(getSuggestedAssets); + const tokens = useSelector(getTokens); + + const trackEvent = useContext(NewMetaMetricsContext); const knownTokenActionableMessage = useMemo(() => { return ( @@ -117,11 +106,38 @@ const ConfirmAddSuggestedToken = (props) => { ); }, [suggestedAssets, tokens, t]); - useEffect(() => { + const handleAddTokensClick = useCallback(async () => { + await Promise.all( + suggestedAssets.map(async ({ asset, id }) => { + await dispatch(acceptWatchAsset(id)); + + trackEvent({ + event: 'Token Added', + category: 'Wallet', + sensitiveProperties: { + token_symbol: asset.symbol, + token_contract_address: asset.address, + token_decimal_precision: asset.decimals, + unlisted: asset.unlisted, + source: 'dapp', + }, + }); + }), + ); + + history.push(mostRecentOverviewPage); + }, [dispatch, history, trackEvent, mostRecentOverviewPage, suggestedAssets]); + + const goBackIfNoSuggestedAssetsOnFirstRender = () => { if (!suggestedAssets.length) { history.push(mostRecentOverviewPage); } - }, [history, suggestedAssets, mostRecentOverviewPage]); + }; + + useEffect(() => { + goBackIfNoSuggestedAssetsOnFirstRender(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return (
@@ -134,30 +150,34 @@ const ConfirmAddSuggestedToken = (props) => { {reusedTokenNameActionableMessage}
-
-
-
{t('token')}
-
{t('balance')}
+
+
+
+ {t('token')} +
+
+ {t('balance')} +
-
+
{suggestedAssets.map(({ asset }) => { return (
-
+
-
+
{getTokenName(asset.name, asset.symbol)}
-
+
@@ -174,7 +194,7 @@ const ConfirmAddSuggestedToken = (props) => { className="page-container__footer-button" onClick={async () => { await Promise.all( - suggestedAssets.map(async ({ id }) => rejectWatchAsset(id)), + suggestedAssets.map(({ id }) => dispatch(rejectWatchAsset(id))), ); history.push(mostRecentOverviewPage); }} @@ -186,15 +206,7 @@ const ConfirmAddSuggestedToken = (props) => { large className="page-container__footer-button" disabled={suggestedAssets.length === 0} - onClick={async () => { - await Promise.all( - suggestedAssets.map(async ({ asset, id }) => { - await acceptWatchAsset(id); - tokenAddedEvent(asset); - }), - ); - history.push(mostRecentOverviewPage); - }} + onClick={handleAddTokensClick} > {t('addToken')} @@ -204,13 +216,4 @@ const ConfirmAddSuggestedToken = (props) => { ); }; -ConfirmAddSuggestedToken.propTypes = { - acceptWatchAsset: PropTypes.func, - history: PropTypes.object, - mostRecentOverviewPage: PropTypes.string.isRequired, - rejectWatchAsset: PropTypes.func, - suggestedAssets: PropTypes.array, - tokens: PropTypes.array, -}; - export default ConfirmAddSuggestedToken; diff --git a/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.stories.js b/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.stories.js index c5cf1f5e4..f984a50bb 100644 --- a/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.stories.js +++ b/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.stories.js @@ -10,7 +10,6 @@ export default { title: 'Pages/ConfirmAddSuggestedToken', id: __filename, argTypes: { - // Data tokens: { control: 'array', table: { category: 'Data' }, @@ -19,26 +18,6 @@ export default { control: 'array', table: { category: 'Data' }, }, - - // Text - mostRecentOverviewPage: { - control: { type: 'text', disable: true }, - table: { category: 'Text' }, - }, - - // Events - acceptWatchAsset: { - action: 'acceptWatchAsset', - table: { category: 'Events' }, - }, - history: { - action: 'history', - table: { category: 'Events' }, - }, - rejectWatchAsset: { - action: 'rejectWatchAsset', - table: { category: 'Events' }, - }, }, }; diff --git a/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.test.js b/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.test.js new file mode 100644 index 000000000..fc35965fd --- /dev/null +++ b/ui/pages/confirm-add-suggested-token/confirm-add-suggested-token.test.js @@ -0,0 +1,142 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import { acceptWatchAsset, rejectWatchAsset } from '../../store/actions'; +import configureStore from '../../store/store'; +import { renderWithProvider } from '../../../test/jest/rendering'; +import ConfirmAddSuggestedToken from '.'; + +const MOCK_SUGGESTED_ASSETS = [ + { + id: 1, + asset: { + address: '0x8b175474e89094c44da98b954eedeac495271d0a', + symbol: 'NEW', + decimals: 18, + image: 'metamark.svg', + unlisted: false, + }, + }, + { + id: 2, + asset: { + address: '0xC8c77482e45F1F44dE1745F52C74426C631bDD51', + symbol: '0XYX', + decimals: 18, + image: '0x.svg', + unlisted: false, + }, + }, +]; + +const MOCK_TOKEN = { + address: '0x108cf70c7d384c552f42c07c41c0e1e46d77ea0d', + symbol: 'TEST', + decimals: '0', +}; + +jest.mock('../../store/actions', () => ({ + acceptWatchAsset: jest.fn().mockReturnValue({ type: 'test' }), + rejectWatchAsset: jest.fn().mockReturnValue({ type: 'test' }), +})); + +const renderComponent = (tokens = []) => { + const store = configureStore({ + metamask: { + suggestedAssets: [...MOCK_SUGGESTED_ASSETS], + tokens, + provider: { chainId: '0x1' }, + }, + history: { + mostRecentOverviewPage: '/', + }, + }); + return renderWithProvider(, store); +}; + +describe('ConfirmAddSuggestedToken Component', () => { + it('should render', () => { + renderComponent(); + + expect(screen.getByText('Add Suggested Tokens')).toBeInTheDocument(); + expect( + screen.getByText('Would you like to import these tokens?'), + ).toBeInTheDocument(); + expect(screen.getByText('Token')).toBeInTheDocument(); + expect(screen.getByText('Balance')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Add Token' }), + ).toBeInTheDocument(); + }); + + it('should render the list of suggested tokens', () => { + renderComponent(); + + for (const { asset } of MOCK_SUGGESTED_ASSETS) { + expect(screen.getByText(asset.symbol)).toBeInTheDocument(); + } + expect(screen.getAllByRole('img')).toHaveLength( + MOCK_SUGGESTED_ASSETS.length, + ); + }); + + it('should dispatch acceptWatchAsset when clicking the "Add Token" button', () => { + renderComponent(); + const addTokenBtn = screen.getByRole('button', { name: 'Add Token' }); + + fireEvent.click(addTokenBtn); + expect(acceptWatchAsset).toHaveBeenCalled(); + }); + + it('should dispatch rejectWatchAsset when clicking the "Cancel" button', () => { + renderComponent(); + const cancelBtn = screen.getByRole('button', { name: 'Cancel' }); + + expect(rejectWatchAsset).toHaveBeenCalledTimes(0); + fireEvent.click(cancelBtn); + expect(rejectWatchAsset).toHaveBeenCalledTimes( + MOCK_SUGGESTED_ASSETS.length, + ); + }); + + describe('when the suggested token address matches an existing token address', () => { + it('should show "already listed" warning', () => { + const mockTokens = [ + { + ...MOCK_TOKEN, + address: MOCK_SUGGESTED_ASSETS[0].asset.address, + }, + ]; + renderComponent(mockTokens); + + expect( + screen.getByText( + 'This action will edit tokens that are already listed in your wallet, which can be used' + + ' to phish you. Only approve if you are certain that you mean to change what these' + + ' tokens represent. Learn more about', + ), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'scams and security risks.' }), + ).toBeInTheDocument(); + }); + }); + + describe('when the suggested token symbol matches an existing token symbol and has a different address', () => { + it('should show "reuses a symbol" warning', () => { + const mockTokens = [ + { + ...MOCK_TOKEN, + symbol: MOCK_SUGGESTED_ASSETS[0].asset.symbol, + }, + ]; + renderComponent(mockTokens); + + expect( + screen.getByText( + 'A token here reuses a symbol from another token you watch, this can be confusing or deceptive.', + ), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/pages/confirm-add-suggested-token/index.js b/ui/pages/confirm-add-suggested-token/index.js index e04fe003b..0711408c9 100644 --- a/ui/pages/confirm-add-suggested-token/index.js +++ b/ui/pages/confirm-add-suggested-token/index.js @@ -1,3 +1 @@ -import ConfirmAddSuggestedToken from './confirm-add-suggested-token.container'; - -export default ConfirmAddSuggestedToken; +export { default } from './confirm-add-suggested-token'; diff --git a/ui/pages/confirm-add-suggested-token/index.scss b/ui/pages/confirm-add-suggested-token/index.scss index 63ee8c657..714e47286 100644 --- a/ui/pages/confirm-add-suggested-token/index.scss +++ b/ui/pages/confirm-add-suggested-token/index.scss @@ -1,4 +1,6 @@ .confirm-add-suggested-token { + padding: 16px; + &__link { @include H7; @@ -6,4 +8,51 @@ color: var(--primary-blue); padding-left: 0; } + + &__header { + @include H7; + + 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-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/selectors/selectors.js b/ui/selectors/selectors.js index 0b90528ca..29b904e01 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -481,6 +481,10 @@ function getSuggestedAssetCount(state) { return suggestedAssets.length; } +export function getSuggestedAssets(state) { + return state.metamask.suggestedAssets; +} + export function getIsMainnet(state) { const chainId = getCurrentChainId(state); return chainId === MAINNET_CHAIN_ID;