diff --git a/ui/ducks/metamask/metamask.js b/ui/ducks/metamask/metamask.js index bb2634288..7aa2e1220 100644 --- a/ui/ducks/metamask/metamask.js +++ b/ui/ducks/metamask/metamask.js @@ -258,6 +258,8 @@ export const getWeb3ShimUsageAlertEnabledness = (state) => export const getUnconnectedAccountAlertShown = (state) => state.metamask.unconnectedAccountAlertShownOrigins; +export const getPendingTokens = (state) => state.metamask.pendingTokens; + export const getTokens = (state) => state.metamask.tokens; export function getCollectiblesDetectionNoticeDismissed(state) { diff --git a/ui/pages/confirm-import-token/confirm-import-token.component.js b/ui/pages/confirm-import-token/confirm-import-token.component.js deleted file mode 100644 index b64baacec..000000000 --- a/ui/pages/confirm-import-token/confirm-import-token.component.js +++ /dev/null @@ -1,141 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { - ASSET_ROUTE, - IMPORT_TOKEN_ROUTE, -} from '../../helpers/constants/routes'; -import Button from '../../components/ui/button'; -import Identicon from '../../components/ui/identicon'; -import TokenBalance from '../../components/ui/token-balance'; - -export default class ConfirmImportToken extends Component { - static contextTypes = { - t: PropTypes.func, - trackEvent: PropTypes.func, - }; - - static propTypes = { - history: PropTypes.object, - clearPendingTokens: PropTypes.func, - addTokens: PropTypes.func, - mostRecentOverviewPage: PropTypes.string.isRequired, - pendingTokens: PropTypes.object, - }; - - componentDidMount() { - const { mostRecentOverviewPage, pendingTokens = {}, history } = this.props; - - if (Object.keys(pendingTokens).length === 0) { - history.push(mostRecentOverviewPage); - } - } - - getTokenName(name, symbol) { - return typeof name === 'undefined' ? symbol : `${name} (${symbol})`; - } - - render() { - const { - history, - addTokens, - clearPendingTokens, - mostRecentOverviewPage, - pendingTokens, - } = this.props; - - return ( -
-
-
- {this.context.t('importTokensCamelCase')} -
-
- {this.context.t('likeToImportTokens')} -
-
-
-
-
-
- {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/pages/confirm-import-token/confirm-import-token.container.js b/ui/pages/confirm-import-token/confirm-import-token.container.js deleted file mode 100644 index ef80986cb..000000000 --- a/ui/pages/confirm-import-token/confirm-import-token.container.js +++ /dev/null @@ -1,24 +0,0 @@ -import { connect } from 'react-redux'; - -import { addTokens, clearPendingTokens } from '../../store/actions'; -import { getMostRecentOverviewPage } from '../../ducks/history/history'; -import ConfirmImportToken from './confirm-import-token.component'; - -const mapStateToProps = (state) => { - const { - metamask: { pendingTokens }, - } = state; - return { - mostRecentOverviewPage: getMostRecentOverviewPage(state), - pendingTokens, - }; -}; - -const mapDispatchToProps = (dispatch) => { - return { - addTokens: (tokens) => dispatch(addTokens(tokens)), - clearPendingTokens: () => dispatch(clearPendingTokens()), - }; -}; - -export default connect(mapStateToProps, mapDispatchToProps)(ConfirmImportToken); diff --git a/ui/pages/confirm-import-token/confirm-import-token.js b/ui/pages/confirm-import-token/confirm-import-token.js new file mode 100644 index 000000000..dcba139b2 --- /dev/null +++ b/ui/pages/confirm-import-token/confirm-import-token.js @@ -0,0 +1,142 @@ +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { + ASSET_ROUTE, + IMPORT_TOKEN_ROUTE, +} from '../../helpers/constants/routes'; +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 { getMostRecentOverviewPage } from '../../ducks/history/history'; +import { getPendingTokens } from '../../ducks/metamask/metamask'; +import { useNewMetricEvent } from '../../hooks/useMetricEvent'; +import { addTokens, clearPendingTokens } from '../../store/actions'; + +const getTokenName = (name, symbol) => { + return name === undefined ? symbol : `${name} (${symbol})`; +}; + +const ConfirmImportToken = () => { + const t = useContext(I18nContext); + const dispatch = useDispatch(); + const history = useHistory(); + + const mostRecentOverviewPage = useSelector(getMostRecentOverviewPage); + const pendingTokens = useSelector(getPendingTokens); + + const [addedToken, setAddedToken] = useState({}); + + const trackTokenAddedEvent = useNewMetricEvent({ + event: 'Token Added', + category: 'Wallet', + sensitiveProperties: { + token_symbol: addedToken.symbol, + token_contract_address: addedToken.address, + token_decimal_precision: addedToken.decimals, + unlisted: addedToken.unlisted, + source: addedToken.isCustom ? 'custom' : 'list', + }, + }); + + const handleAddTokens = useCallback(async () => { + await dispatch(addTokens(pendingTokens)); + + const addedTokenValues = Object.values(pendingTokens); + const firstTokenAddress = addedTokenValues?.[0].address?.toLowerCase(); + + addedTokenValues.forEach((pendingToken) => { + setAddedToken({ ...pendingToken }); + }); + dispatch(clearPendingTokens()); + + if (firstTokenAddress) { + history.push(`${ASSET_ROUTE}/${firstTokenAddress}`); + } else { + history.push(mostRecentOverviewPage); + } + }, [dispatch, history, mostRecentOverviewPage, pendingTokens]); + + useEffect(() => { + if (Object.keys(addedToken).length) { + trackTokenAddedEvent(); + } + }, [addedToken, trackTokenAddedEvent]); + + useEffect(() => { + if (Object.keys(pendingTokens).length === 0) { + history.push(mostRecentOverviewPage); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+
+ {t('importTokensCamelCase')} +
+
+ {t('likeToImportTokens')} +
+
+
+
+
+
{t('token')}
+
{t('balance')}
+
+
+ {Object.entries(pendingTokens).map(([address, token]) => { + const { name, symbol } = token; + + return ( +
+
+ +
+ {getTokenName(name, symbol)} +
+
+
+ +
+
+ ); + })} +
+
+
+
+ +
+
+ ); +}; + +export default ConfirmImportToken; diff --git a/ui/pages/confirm-import-token/confirm-import-token.stories.js b/ui/pages/confirm-import-token/confirm-import-token.stories.js index 2ec34bf8d..413327382 100644 --- a/ui/pages/confirm-import-token/confirm-import-token.stories.js +++ b/ui/pages/confirm-import-token/confirm-import-token.stories.js @@ -1,8 +1,6 @@ /* eslint-disable react/prop-types */ import React, { useEffect } from 'react'; -import { createBrowserHistory } from 'history'; -import { text } from '@storybook/addon-knobs'; import { store, getNewState } from '../../../.storybook/preview'; import { tokens } from '../../../.storybook/initial-states/approval-screens/add-token'; import { updateMetamaskState } from '../../store/actions'; @@ -11,45 +9,39 @@ import ConfirmAddToken from '.'; export default { title: 'Pages/ConfirmImportToken', id: __filename, + + argTypes: { + pendingTokens: { + control: 'object', + table: { category: 'Data' }, + }, + }, }; -const history = createBrowserHistory(); +const PageSet = ({ children, pendingTokens }) => { + const { metamask: state } = store.getState(); -const PageSet = ({ children }) => { - const symbol = text('symbol', 'TRDT'); - const state = store.getState(); - const pendingTokensState = state.metamask.pendingTokens; - // only change the first token in the list useEffect(() => { - const pendingTokens = { ...pendingTokensState }; - pendingTokens['0x33f90dee07c6e8b9682dd20f73e6c358b2ed0f03'].symbol = symbol; store.dispatch( updateMetamaskState( - getNewState(state.metamask, { + getNewState(state, { pendingTokens, }), ), ); - }, [symbol, pendingTokensState, state.metamask]); + }, [state, pendingTokens]); return children; }; -export const DefaultStory = () => { - const { metamask: state } = store.getState(); - store.dispatch( - updateMetamaskState( - getNewState(state, { - pendingTokens: tokens, - }), - ), - ); - +export const DefaultStory = ({ pendingTokens }) => { return ( - - + + ); }; - +DefaultStory.args = { + pendingTokens: { ...tokens }, +}; DefaultStory.storyName = 'Default'; diff --git a/ui/pages/confirm-import-token/confirm-import-token.test.js b/ui/pages/confirm-import-token/confirm-import-token.test.js new file mode 100644 index 000000000..5d9cb28a2 --- /dev/null +++ b/ui/pages/confirm-import-token/confirm-import-token.test.js @@ -0,0 +1,128 @@ +import React from 'react'; +import reactRouterDom from 'react-router-dom'; +import { fireEvent, screen } from '@testing-library/react'; +import { + ASSET_ROUTE, + IMPORT_TOKEN_ROUTE, +} from '../../helpers/constants/routes'; +import { addTokens, clearPendingTokens } from '../../store/actions'; +import configureStore from '../../store/store'; +import { renderWithProvider } from '../../../test/jest'; +import ConfirmImportToken from '.'; + +const MOCK_PENDING_TOKENS = { + '0x6b175474e89094c44da98b954eedeac495271d0f': { + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + symbol: 'META', + decimals: 18, + image: 'metamark.svg', + }, + '0xB8c77482e45F1F44dE1745F52C74426C631bDD52': { + address: '0xB8c77482e45F1F44dE1745F52C74426C631bDD52', + symbol: '0X', + decimals: 18, + image: '0x.svg', + }, +}; + +jest.mock('../../store/actions', () => ({ + addTokens: jest.fn().mockReturnValue({ type: 'test' }), + clearPendingTokens: jest + .fn() + .mockReturnValue({ type: 'CLEAR_PENDING_TOKENS' }), +})); + +const renderComponent = (mockPendingTokens = MOCK_PENDING_TOKENS) => { + const store = configureStore({ + metamask: { + pendingTokens: { ...mockPendingTokens }, + provider: { chainId: '0x1' }, + }, + history: { + mostRecentOverviewPage: '/', + }, + }); + + return renderWithProvider(, store); +}; + +describe('ConfirmImportToken Component', () => { + const mockHistoryPush = jest.fn(); + + beforeEach(() => { + jest + .spyOn(reactRouterDom, 'useHistory') + .mockImplementation() + .mockReturnValue({ push: mockHistoryPush }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render', () => { + renderComponent(); + + const [title, importTokensBtn] = screen.queryAllByText('Import Tokens'); + + expect(title).toBeInTheDocument(title); + 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: 'Back' })).toBeInTheDocument(); + expect(importTokensBtn).toBeInTheDocument(); + }); + + it('should render the list of tokens', () => { + renderComponent(); + + Object.values(MOCK_PENDING_TOKENS).forEach((token) => { + expect(screen.getByText(token.symbol)).toBeInTheDocument(); + }); + }); + + it('should go to "IMPORT_TOKEN_ROUTE" route when clicking the "Back" button', async () => { + renderComponent(); + + const backBtn = screen.getByRole('button', { name: 'Back' }); + + await fireEvent.click(backBtn); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledWith(IMPORT_TOKEN_ROUTE); + }); + + it('should dispatch clearPendingTokens and redirect to the first token page when clicking the "Import Tokens" button', async () => { + const mockFirstPendingTokenAddress = + '0xe83cccfabd4ed148903bf36d4283ee7c8b3494d1'; + const mockPendingTokens = { + [mockFirstPendingTokenAddress]: { + address: mockFirstPendingTokenAddress, + symbol: 'CVL', + decimals: 18, + image: 'CVL_token.svg', + }, + '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e': { + address: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', + symbol: 'GLA', + decimals: 18, + image: 'gladius.svg', + }, + }; + renderComponent(mockPendingTokens); + + const importTokensBtn = screen.getByRole('button', { + name: 'Import Tokens', + }); + + await fireEvent.click(importTokensBtn); + + expect(addTokens).toHaveBeenCalled(); + expect(clearPendingTokens).toHaveBeenCalled(); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledWith( + `${ASSET_ROUTE}/${mockFirstPendingTokenAddress}`, + ); + }); +}); diff --git a/ui/pages/confirm-import-token/index.js b/ui/pages/confirm-import-token/index.js index cddbd5032..4443efa6b 100644 --- a/ui/pages/confirm-import-token/index.js +++ b/ui/pages/confirm-import-token/index.js @@ -1,3 +1,3 @@ -import ConfirmImportToken from './confirm-import-token.container'; +import ConfirmImportToken from './confirm-import-token'; export default ConfirmImportToken;