1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 01:47:00 +01:00

Show Bridge button in TokenOverview component (#18630)

* Show Bridge button in TokenOverview component

* Hide Swap button in token overview page when network is not supported
This commit is contained in:
micaelae 2023-04-20 19:27:18 -07:00 committed by GitHub
parent 8e6f4b8831
commit a144b75fe8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 314 additions and 68 deletions

View File

@ -8,3 +8,46 @@ export const ALLOWED_BRIDGE_CHAIN_IDS = [
CHAIN_IDS.OPTIMISM, CHAIN_IDS.OPTIMISM,
CHAIN_IDS.ARBITRUM, CHAIN_IDS.ARBITRUM,
]; ];
export const ALLOWED_BRIDGE_TOKEN_ADDRESSES = {
[CHAIN_IDS.MAINNET]: [
'0xdac17f958d2ee523a2206206994597c13d831ec7',
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
'0x6b175474e89094c44da98b954eedeac495271d0f',
'0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0',
'0x8965349fb649a33a30cbfda057d8ec2c48abe2a2',
],
[CHAIN_IDS.BSC]: [
'0x55d398326f99059ff775485246999027b3197955',
'0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d',
'0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3',
'0x2170ed0880ac9a755fd29b2688956bd959f933f8',
'0xcc42724c6683b7e57334c4e856f4c9965ed682bd',
'0x1ce0c2827e2ef14d5c4f29a091d735a204794041',
],
[CHAIN_IDS.POLYGON]: [
'0xc2132d05d31c914a87c6611c10748aeb04b58e8f',
'0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
'0x8f3cf7ad23cd3cadbd9735aff958023239c6a063',
'0x7ceb23fd6bc0add59e62ac25578270cff1b9f619',
'0x2c89bbc92bd86f8075d1decc58c7f4e0107f286b',
],
[CHAIN_IDS.AVALANCHE]: [
'0xc7198437980c041c805a1edcba50c1ce5db95118',
'0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7',
'0xa7d7079b0fead91f3e65f86e8915cb59c1a4c664',
'0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e',
'0xd586e7f844cea2f87f50152665bcbc2c279d8d70',
'0x49d5c2bdffac6ce2bfdb6640f4f80f226bc10bab',
],
[CHAIN_IDS.OPTIMISM]: [
'0x94b008aa00579c1307b0ef2c499ad98a8ce58e58',
'0x7f5c764cbc14f9669b88837ca1490cca17c31607',
'0xda10009cbd5d07dd0cecc66161fc93d7c9000da1',
],
[CHAIN_IDS.ARBITRUM]: [
'0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9',
'0xff970a61a04b1ca14834a43f5de4533ebddb5cc8',
'0xda10009cbd5d07dd0cecc66161fc93d7c9000da1',
],
};

View File

@ -2,7 +2,7 @@ import React, { useContext } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import classnames from 'classnames'; import classnames from 'classnames';
import { useHistory } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import Identicon from '../../ui/identicon'; import Identicon from '../../ui/identicon';
import { I18nContext } from '../../../contexts/i18n'; import { I18nContext } from '../../../contexts/i18n';
@ -52,6 +52,7 @@ const EthOverview = ({ className }) => {
const t = useContext(I18nContext); const t = useContext(I18nContext);
const trackEvent = useContext(MetaMetricsContext); const trackEvent = useContext(MetaMetricsContext);
const history = useHistory(); const history = useHistory();
const location = useLocation();
const keyring = useSelector(getCurrentKeyring); const keyring = useSelector(getCurrentKeyring);
const usingHardwareWallet = isHardwareKeyring(keyring?.type); const usingHardwareWallet = isHardwareKeyring(keyring?.type);
const balanceIsCached = useSelector(isBalanceCached); const balanceIsCached = useSelector(isBalanceCached);
@ -246,7 +247,9 @@ const EthOverview = ({ className }) => {
const portfolioUrl = process.env.PORTFOLIO_URL; const portfolioUrl = process.env.PORTFOLIO_URL;
const bridgeUrl = `${portfolioUrl}/bridge`; const bridgeUrl = `${portfolioUrl}/bridge`;
global.platform.openTab({ global.platform.openTab({
url: `${bridgeUrl}?metamaskEntry=ext`, url: `${bridgeUrl}?metamaskEntry=ext_bridge_button${
location.pathname.includes('asset') ? '&token=native' : ''
}`,
}); });
trackEvent({ trackEvent({
category: MetaMetricsEventCategory.Navigation, category: MetaMetricsEventCategory.Navigation,

View File

@ -181,7 +181,9 @@ describe('EthOverview', () => {
await waitFor(() => await waitFor(() =>
expect(openTabSpy).toHaveBeenCalledWith({ expect(openTabSpy).toHaveBeenCalledWith({
url: expect.stringContaining(`/bridge?metamaskEntry=ext`), url: expect.stringContaining(
'/bridge?metamaskEntry=ext_bridge_button',
),
}), }),
); );
}); });

View File

@ -114,7 +114,14 @@
align-items: center; align-items: center;
margin: 16px 0; margin: 16px 0;
padding: 0 16px; padding: 0 16px;
max-width: 100%; max-width: 326px;
}
&__primary-container {
display: flex;
max-width: inherit;
justify-content: center;
flex-wrap: wrap;
} }
&__primary-balance { &__primary-balance {
@ -131,6 +138,11 @@
color: var(--color-text-alternative); color: var(--color-text-alternative);
} }
&__portfolio-button {
height: inherit;
padding-inline-start: 16px;
}
&__button:last-of-type { &__button:last-of-type {
margin-right: 0; margin-right: 0;
} }

View File

@ -4,7 +4,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import Identicon from '../../ui/identicon'; import Identicon from '../../ui/identicon';
import Tooltip from '../../ui/tooltip';
import CurrencyDisplay from '../../ui/currency-display'; import CurrencyDisplay from '../../ui/currency-display';
import { I18nContext } from '../../../contexts/i18n'; import { I18nContext } from '../../../contexts/i18n';
import { isHardwareKeyring } from '../../../helpers/utils/hardware'; import { isHardwareKeyring } from '../../../helpers/utils/hardware';
@ -20,6 +19,7 @@ import {
getCurrentKeyring, getCurrentKeyring,
getIsSwapsChain, getIsSwapsChain,
getIsBuyableChain, getIsBuyableChain,
getIsBridgeToken,
} from '../../../selectors'; } from '../../../selectors';
import IconButton from '../../ui/icon-button'; import IconButton from '../../ui/icon-button';
@ -35,8 +35,10 @@ import {
import { AssetType } from '../../../../shared/constants/transaction'; import { AssetType } from '../../../../shared/constants/transaction';
import useRamps from '../../../hooks/experiences/useRamps'; import useRamps from '../../../hooks/experiences/useRamps';
import { Icon, IconName } from '../../component-library'; import { ButtonIcon, Icon, IconName } from '../../component-library';
import { IconColor } from '../../../helpers/constants/design-system'; import { IconColor } from '../../../helpers/constants/design-system';
import { BUTTON_ICON_SIZES } from '../../component-library/button-icon/deprecated';
import WalletOverview from './wallet-overview'; import WalletOverview from './wallet-overview';
const TokenOverview = ({ className, token }) => { const TokenOverview = ({ className, token }) => {
@ -55,7 +57,7 @@ const TokenOverview = ({ className, token }) => {
token.symbol, token.symbol,
); );
const isSwapsChain = useSelector(getIsSwapsChain); const isSwapsChain = useSelector(getIsSwapsChain);
const isBridgeToken = useSelector(getIsBridgeToken(token.address));
const isBuyableChain = useSelector(getIsBuyableChain); const isBuyableChain = useSelector(getIsBuyableChain);
const { openBuyCryptoInPdapp } = useRamps(); const { openBuyCryptoInPdapp } = useRamps();
@ -75,11 +77,42 @@ const TokenOverview = ({ className, token }) => {
<WalletOverview <WalletOverview
balance={ balance={
<div className="token-overview__balance"> <div className="token-overview__balance">
<CurrencyDisplay <div className="token-overview__primary-container">
className="token-overview__primary-balance" <CurrencyDisplay
displayValue={balanceToRender} style={{ display: 'contents' }}
suffix={token.symbol} className="token-overview__primary-balance"
/> displayValue={balanceToRender}
suffix={token.symbol}
/>
<ButtonIcon
className="token-overview__portfolio-button"
data-testid="home__portfolio-site"
color={IconColor.primaryDefault}
iconName={IconName.Diagram}
ariaLabel={t('portfolio')}
size={BUTTON_ICON_SIZES.LG}
onClick={() => {
const portfolioUrl = process.env.PORTFOLIO_URL;
global.platform.openTab({
url: `${portfolioUrl}?metamaskEntry=ext`,
});
trackEvent(
{
category: MetaMetricsEventCategory.Home,
event: MetaMetricsEventName.PortfolioLinkClicked,
properties: {
url: portfolioUrl,
},
},
{
contextPropsIntoEventProperties: [
MetaMetricsContextProp.PageTitle,
],
},
);
}}
/>
</div>
{formattedFiatBalance ? ( {formattedFiatBalance ? (
<CurrencyDisplay <CurrencyDisplay
className="token-overview__secondary-balance" className="token-overview__secondary-balance"
@ -145,17 +178,16 @@ const TokenOverview = ({ className, token }) => {
data-testid="eth-overview-send" data-testid="eth-overview-send"
disabled={token.isERC721} disabled={token.isERC721}
/> />
<IconButton {isSwapsChain && (
className="token-overview__button" <IconButton
disabled={!isSwapsChain} className="token-overview__button"
Icon={ Icon={
<Icon <Icon
name={IconName.SwapHorizontal} name={IconName.SwapHorizontal}
color={IconColor.primaryInverse} color={IconColor.primaryInverse}
/> />
} }
onClick={() => { onClick={() => {
if (isSwapsChain) {
trackEvent({ trackEvent({
event: MetaMetricsEventName.NavSwapButtonClicked, event: MetaMetricsEventName.NavSwapButtonClicked,
category: MetaMetricsEventCategory.Swaps, category: MetaMetricsEventCategory.Swaps,
@ -179,51 +211,38 @@ const TokenOverview = ({ className, token }) => {
} else { } else {
history.push(BUILD_QUOTE_ROUTE); history.push(BUILD_QUOTE_ROUTE);
} }
}}
label={t('swap')}
tooltipRender={null}
/>
)}
{isBridgeToken && (
<IconButton
className="token-overview__button"
data-testid="token-overview-bridge"
Icon={
<Icon name={IconName.Bridge} color={IconColor.primaryInverse} />
} }
}} label={t('bridge')}
label={t('swap')} onClick={() => {
tooltipRender={ const portfolioUrl = process.env.PORTFOLIO_URL;
isSwapsChain
? null const bridgeUrl = `${portfolioUrl}/bridge`;
: (contents) => ( global.platform.openTab({
<Tooltip url: `${bridgeUrl}?metamaskEntry=ext_bridge_button&token=${token.address}`,
title={t('currentlyUnavailable')} });
position="bottom" trackEvent({
disabled={isSwapsChain} category: MetaMetricsEventCategory.Navigation,
> event: MetaMetricsEventName.BridgeLinkClicked,
{contents}
</Tooltip>
)
}
/>
<IconButton
className="eth-overview__button"
Icon={
<Icon name={IconName.Diagram} color={IconColor.primaryInverse} />
}
label={t('portfolio')}
data-testid="home__portfolio-site"
onClick={() => {
const portfolioUrl = process.env.PORTFOLIO_URL;
global.platform.openTab({
url: `${portfolioUrl}?metamaskEntry=ext`,
});
trackEvent(
{
category: MetaMetricsEventCategory.Home,
event: MetaMetricsEventName.PortfolioLinkClicked,
properties: { properties: {
url: portfolioUrl, location: 'Token Overview',
text: 'Bridge',
}, },
}, });
{ }}
contextPropsIntoEventProperties: [ tooltipRender={null}
MetaMetricsContextProp.PageTitle, />
], )}
},
);
}}
/>
</> </>
} }
className={className} className={className}

View File

@ -23,6 +23,7 @@ jest.mock('../../../../shared/constants/network', () => ({
}, },
}, },
})); }));
let openTabSpy;
describe('TokenOverview', () => { describe('TokenOverview', () => {
const mockStore = { const mockStore = {
@ -68,6 +69,11 @@ describe('TokenOverview', () => {
openTab: jest.fn(), openTab: jest.fn(),
}, },
}); });
openTabSpy = jest.spyOn(global.platform, 'openTab');
});
beforeEach(() => {
openTabSpy.mockClear();
}); });
const token = { const token = {
@ -209,8 +215,6 @@ describe('TokenOverview', () => {
mockedStoreWithBuyableChainId, mockedStoreWithBuyableChainId,
); );
const openTabSpy = jest.spyOn(global.platform, 'openTab');
const { queryByTestId } = renderWithProvider( const { queryByTestId } = renderWithProvider(
<TokenOverview token={token} />, <TokenOverview token={token} />,
mockedStore, mockedStore,
@ -228,5 +232,137 @@ describe('TokenOverview', () => {
}), }),
); );
}); });
it('should always show the Portfolio button', () => {
const mockToken = {
name: 'test',
isERC721: false,
address: '0x7ceb23fd6bc0add59e62ac25578270cff1B9f619',
symbol: 'test',
};
const { queryByTestId } = renderWithProvider(
<TokenOverview token={mockToken} />,
store,
);
const portfolioButton = queryByTestId('home__portfolio-site');
expect(portfolioButton).toBeInTheDocument();
});
it('should open the Portfolio URI when clicking on Portfolio button', async () => {
const mockToken = {
name: 'test',
isERC721: false,
address: '0x7ceb23fd6bc0add59e62ac25578270cff1B9f619',
symbol: 'test',
};
const { queryByTestId } = renderWithProvider(
<TokenOverview token={mockToken} />,
store,
);
const portfolioButton = queryByTestId('home__portfolio-site');
expect(portfolioButton).toBeInTheDocument();
expect(portfolioButton).not.toBeDisabled();
fireEvent.click(portfolioButton);
expect(openTabSpy).toHaveBeenCalledTimes(1);
await waitFor(() =>
expect(openTabSpy).toHaveBeenCalledWith({
url: expect.stringContaining(`?metamaskEntry=ext`),
}),
);
});
it('should show the Bridge button if chain id and token are supported', async () => {
const mockToken = {
name: 'test',
isERC721: false,
address: '0x7ceb23fd6bc0add59e62ac25578270cff1B9f619',
symbol: 'test',
};
const mockedStoreWithBridgeableChainId = {
metamask: {
...mockStore.metamask,
provider: { type: 'test', chainId: CHAIN_IDS.POLYGON },
},
};
const mockedStore = configureMockStore([thunk])(
mockedStoreWithBridgeableChainId,
);
const { queryByTestId } = renderWithProvider(
<TokenOverview token={mockToken} />,
mockedStore,
);
const bridgeButton = queryByTestId('token-overview-bridge');
expect(bridgeButton).toBeInTheDocument();
expect(bridgeButton).not.toBeDisabled();
fireEvent.click(bridgeButton);
expect(openTabSpy).toHaveBeenCalledTimes(1);
await waitFor(() =>
expect(openTabSpy).toHaveBeenCalledWith({
url: expect.stringContaining(
'/bridge?metamaskEntry=ext_bridge_button&token=0x7ceb23fd6bc0add59e62ac25578270cff1B9f619',
),
}),
);
});
it('should not show the Bridge button if chain id is not supported', async () => {
const mockToken = {
name: 'test',
isERC721: false,
address: '0x7ceb23fd6bc0add59e62ac25578270cff1B9f619',
symbol: 'test',
};
const mockedStoreWithBridgeableChainId = {
metamask: {
...mockStore.metamask,
provider: { type: 'test', chainId: CHAIN_IDS.FANTOM },
},
};
const mockedStore = configureMockStore([thunk])(
mockedStoreWithBridgeableChainId,
);
const { queryByTestId } = renderWithProvider(
<TokenOverview token={mockToken} />,
mockedStore,
);
const bridgeButton = queryByTestId('token-overview-bridge');
expect(bridgeButton).not.toBeInTheDocument();
});
it('should not show the Bridge button if token is not supported', async () => {
const mockToken = {
name: 'test',
isERC721: false,
address: '0x7ceb23fd6bc0add59e62ac25578270cff1B9f620',
symbol: 'test',
};
const mockedStoreWithBridgeableChainId = {
metamask: {
...mockStore.metamask,
provider: { type: 'test', chainId: CHAIN_IDS.POLYGON },
},
};
const mockedStore = configureMockStore([thunk])(
mockedStoreWithBridgeableChainId,
);
const { queryByTestId } = renderWithProvider(
<TokenOverview token={mockToken} />,
mockedStore,
);
const bridgeButton = queryByTestId('token-overview-bridge');
expect(bridgeButton).not.toBeInTheDocument();
});
}); });
}); });

View File

@ -46,7 +46,10 @@ import {
ALLOWED_DEV_SWAPS_CHAIN_IDS, ALLOWED_DEV_SWAPS_CHAIN_IDS,
} from '../../shared/constants/swaps'; } from '../../shared/constants/swaps';
import { ALLOWED_BRIDGE_CHAIN_IDS } from '../../shared/constants/bridge'; import {
ALLOWED_BRIDGE_CHAIN_IDS,
ALLOWED_BRIDGE_TOKEN_ADDRESSES,
} from '../../shared/constants/bridge';
import { import {
shortenAddress, shortenAddress,
@ -747,6 +750,15 @@ export function getIsBridgeChain(state) {
return ALLOWED_BRIDGE_CHAIN_IDS.includes(chainId); return ALLOWED_BRIDGE_CHAIN_IDS.includes(chainId);
} }
export const getIsBridgeToken = (tokenAddress) => (state) => {
const chainId = getCurrentChainId(state);
const isBridgeChain = getIsBridgeChain(state);
return (
isBridgeChain &&
ALLOWED_BRIDGE_TOKEN_ADDRESSES[chainId].includes(tokenAddress.toLowerCase())
);
};
export function getIsBuyableChain(state) { export function getIsBuyableChain(state) {
const chainId = getCurrentChainId(state); const chainId = getCurrentChainId(state);
return Object.keys(BUYABLE_CHAINS_MAP).includes(chainId); return Object.keys(BUYABLE_CHAINS_MAP).includes(chainId);

View File

@ -450,4 +450,23 @@ describe('Selectors', () => {
const isFantomSupported = selectors.getIsBridgeChain(mockState); const isFantomSupported = selectors.getIsBridgeChain(mockState);
expect(isFantomSupported).toBeFalsy(); expect(isFantomSupported).toBeFalsy();
}); });
it('#getIsBridgeToken', () => {
mockState.metamask.provider.chainId = '0xa';
const isOptimismTokenSupported = selectors.getIsBridgeToken(
'0x94B008aa00579c1307b0ef2c499ad98a8ce58e58',
)(mockState);
expect(isOptimismTokenSupported).toBeTruthy();
const isOptimismUnknownTokenSupported = selectors.getIsBridgeToken(
'0x94B008aa00579c1307b0ef2c499ad98a8ce58e60',
)(mockState);
expect(isOptimismUnknownTokenSupported).toBeFalsy();
mockState.metamask.provider.chainId = '0xfa';
const isFantomTokenSupported = selectors.getIsBridgeToken(
'0x94B008aa00579c1307b0ef2c499ad98a8ce58e58',
)(mockState);
expect(isFantomTokenSupported).toBeFalsy();
});
}); });