mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-12-23 01:39:44 +01:00
Show custom tokens in Swaps, add a custom token in Swaps (#11200)
* Show custom tokens in Swaps * Add messages for adding a custom token in Swaps * Add the first version of importing custom tokens in swaps * Fix lint rules * Create a new component: ImportToken * Remove a pointer cursor from regular heading * Fix a CSS issue for tokens with long names * Update a comment * Don’t return a custom token if it doesn’t have symbol or decimals * Only search by contract address if nothing was found * Track “Token Imported” event * Fix unit tests * Import tracking for “Token Imported”, increase token icon font size * Disable token import for Source Token * Update logic and content for notifications, update tests * Do not hide a dropdown placeholder on click, so a user can click on a link * Update a key name * Update styling for the “danger” type notification in Swaps * Show either a warning or danger notification based on token verification occurences * Remove testnets from SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP * Use the “shouldSearchForImports” prop * Create a new function for handling token import: “onOpenImportTokenModalClick” * Filter token duplicities before iterating over tokens * Use “address” instead of “symbol” for checking uniqueness * Trigger Build * Use a new API (/token) to get token data for importing in Swaps * Temporarily decrese Jest threshold for functions
This commit is contained in:
parent
6700c460fd
commit
c3b79bb358
@ -52,6 +52,10 @@
|
||||
"addContact": {
|
||||
"message": "Add contact"
|
||||
},
|
||||
"addCustomTokenByContractAddress": {
|
||||
"message": "Can’t find a token? You can manually add any token by pasting its address. Token contract addresses can be found on $1.",
|
||||
"description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum"
|
||||
},
|
||||
"addEthereumChainConfirmationDescription": {
|
||||
"message": "This will allow this network to be used within MetaMask."
|
||||
},
|
||||
@ -410,6 +414,9 @@
|
||||
"continueToWyre": {
|
||||
"message": "Continue to Wyre"
|
||||
},
|
||||
"contract": {
|
||||
"message": "Contract"
|
||||
},
|
||||
"contractAddressError": {
|
||||
"message": "You are sending tokens to the token's contract address. This may result in the loss of these tokens."
|
||||
},
|
||||
@ -893,6 +900,12 @@
|
||||
"message": "or $1",
|
||||
"description": "$1 represents the text from `importAccountLinkText` as a link"
|
||||
},
|
||||
"importTokenQuestion": {
|
||||
"message": "Import token?"
|
||||
},
|
||||
"importTokenWarning": {
|
||||
"message": "Anyone can create a token with any name, including fake versions of existing tokens. Add and trade at your own risk!"
|
||||
},
|
||||
"importWallet": {
|
||||
"message": "Import wallet"
|
||||
},
|
||||
@ -2066,13 +2079,13 @@
|
||||
"message": "Swap $1 to $2",
|
||||
"description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap."
|
||||
},
|
||||
"swapTokenVerificationAddedManually": {
|
||||
"message": "This token has been added manually."
|
||||
},
|
||||
"swapTokenVerificationMessage": {
|
||||
"message": "Always confirm the token address on $1.",
|
||||
"description": "Points the user to Etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"Etherscan\" followed by an info icon that shows more info on hover."
|
||||
},
|
||||
"swapTokenVerificationNoSource": {
|
||||
"message": "This token has not been verified."
|
||||
},
|
||||
"swapTokenVerificationOnlyOneSource": {
|
||||
"message": "Only verified on 1 source."
|
||||
},
|
||||
@ -2358,6 +2371,10 @@
|
||||
"message": "Verify this token on $1",
|
||||
"description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\""
|
||||
},
|
||||
"verifyThisUnconfirmedTokenOn": {
|
||||
"message": "Verify this token on $1 and make sure this is the token you want to trade.",
|
||||
"description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\""
|
||||
},
|
||||
"viewAccount": {
|
||||
"message": "View Account"
|
||||
},
|
||||
|
@ -6,7 +6,7 @@ module.exports = {
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 32.75,
|
||||
functions: 43.31,
|
||||
functions: 42.9,
|
||||
lines: 43.12,
|
||||
statements: 43.67,
|
||||
},
|
||||
|
@ -63,6 +63,7 @@ const SWAPS_TESTNET_CHAIN_ID = '0x539';
|
||||
const SWAPS_TESTNET_HOST = 'https://metaswap-api.airswap-dev.codefi.network';
|
||||
|
||||
const BSC_DEFAULT_BLOCK_EXPLORER_URL = 'https://bscscan.com/';
|
||||
const MAINNET_DEFAULT_BLOCK_EXPLORER_URL = 'https://etherscan.io/';
|
||||
|
||||
export const ALLOWED_SWAPS_CHAIN_IDS = {
|
||||
[MAINNET_CHAIN_ID]: true,
|
||||
@ -90,4 +91,5 @@ export const SWAPS_CHAINID_DEFAULT_TOKEN_MAP = {
|
||||
|
||||
export const SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP = {
|
||||
[BSC_CHAIN_ID]: BSC_DEFAULT_BLOCK_EXPLORER_URL,
|
||||
[MAINNET_CHAIN_ID]: MAINNET_DEFAULT_BLOCK_EXPLORER_URL,
|
||||
};
|
||||
|
@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import contractMap from '@metamask/contract-metadata';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import { isEqual, shuffle } from 'lodash';
|
||||
import { isEqual, shuffle, uniqBy } from 'lodash';
|
||||
import { getTokenFiatAmount } from '../helpers/utils/token-util';
|
||||
import {
|
||||
getTokenExchangeRates,
|
||||
@ -119,7 +119,12 @@ export function useTokensToSearch({ usersTokens = [], topTokens = {} }) {
|
||||
others: [],
|
||||
};
|
||||
|
||||
memoizedTokensToSearch.forEach((token) => {
|
||||
const memoizedSwapsAndUserTokensWithoutDuplicities = uniqBy(
|
||||
[...memoizedTokensToSearch, ...memoizedUsersToken],
|
||||
'address',
|
||||
);
|
||||
|
||||
memoizedSwapsAndUserTokensWithoutDuplicities.forEach((token) => {
|
||||
const renderableDataToken = getRenderableTokenData(
|
||||
{ ...usersTokensAddressMap[token.address], ...token },
|
||||
tokenConversionRates,
|
||||
@ -129,8 +134,7 @@ export function useTokensToSearch({ usersTokens = [], topTokens = {} }) {
|
||||
);
|
||||
if (
|
||||
isSwapsDefaultTokenSymbol(renderableDataToken.symbol, chainId) ||
|
||||
(usersTokensAddressMap[token.address] &&
|
||||
Number(renderableDataToken.balance ?? 0) !== 0)
|
||||
usersTokensAddressMap[token.address]
|
||||
) {
|
||||
tokensToSearchBuckets.owned.push(renderableDataToken);
|
||||
} else if (memoizedTopTokens[token.address]) {
|
||||
|
@ -51,12 +51,18 @@
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: $Red-100;
|
||||
border: 1px solid $Red-500;
|
||||
background: $Red-000;
|
||||
border: 1px solid $Red-300;
|
||||
justify-content: flex-start;
|
||||
|
||||
.actionable-message__message {
|
||||
color: $Red-500;
|
||||
color: $Black-100;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
button {
|
||||
background: $Red-500;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -27,7 +27,7 @@ describe('AwaitingSwap', () => {
|
||||
store,
|
||||
);
|
||||
expect(getByText('Processing')).toBeInTheDocument();
|
||||
expect(getByText('View on Etherscan')).toBeInTheDocument();
|
||||
expect(getByText('ETH')).toBeInTheDocument();
|
||||
expect(getByText('View in activity')).toBeInTheDocument();
|
||||
expect(
|
||||
document.querySelector('.awaiting-swap__main-descrption'),
|
||||
|
@ -333,6 +333,38 @@ export default function BuildQuote({
|
||||
dispatch(resetSwapsPostFetchState());
|
||||
}, [dispatch]);
|
||||
|
||||
const BlockExplorerLink = () => {
|
||||
return (
|
||||
<a
|
||||
className="build-quote__token-etherscan-link build-quote__underline"
|
||||
key="build-quote-etherscan-link"
|
||||
onClick={() => {
|
||||
blockExplorerLinkClickedEvent();
|
||||
global.platform.openTab({
|
||||
url: blockExplorerTokenLink,
|
||||
});
|
||||
}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{blockExplorerLabel}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
let tokenVerificationDescription = '';
|
||||
if (blockExplorerTokenLink) {
|
||||
if (occurances === 1) {
|
||||
tokenVerificationDescription = t('verifyThisTokenOn', [
|
||||
<BlockExplorerLink key="block-explorer-link" />,
|
||||
]);
|
||||
} else if (occurances === 0) {
|
||||
tokenVerificationDescription = t('verifyThisUnconfirmedTokenOn', [
|
||||
<BlockExplorerLink key="block-explorer-link" />,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="build-quote">
|
||||
<div className="build-quote__content">
|
||||
@ -434,37 +466,21 @@ export default function BuildQuote({
|
||||
listContainerClassName="build-quote__open-to-dropdown"
|
||||
hideRightLabels
|
||||
defaultToAll
|
||||
shouldSearchForImports
|
||||
/>
|
||||
</div>
|
||||
{toTokenIsNotDefault &&
|
||||
(occurances < 2 ? (
|
||||
<ActionableMessage
|
||||
type={occurances === 1 ? 'warning' : 'danger'}
|
||||
message={
|
||||
<div className="build-quote__token-verification-warning-message">
|
||||
<div className="build-quote__bold">
|
||||
{occurances === 1
|
||||
? t('swapTokenVerificationOnlyOneSource')
|
||||
: t('swapTokenVerificationNoSource')}
|
||||
</div>
|
||||
<div>
|
||||
{blockExplorerTokenLink &&
|
||||
t('verifyThisTokenOn', [
|
||||
<a
|
||||
className="build-quote__token-etherscan-link build-quote__underline"
|
||||
key="build-quote-etherscan-link"
|
||||
onClick={() => {
|
||||
blockExplorerLinkClickedEvent();
|
||||
global.platform.openTab({
|
||||
url: blockExplorerTokenLink,
|
||||
});
|
||||
}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{blockExplorerLabel}
|
||||
</a>,
|
||||
])}
|
||||
: t('swapTokenVerificationAddedManually')}
|
||||
</div>
|
||||
<div>{tokenVerificationDescription}</div>
|
||||
</div>
|
||||
}
|
||||
primaryAction={
|
||||
@ -475,7 +491,6 @@ export default function BuildQuote({
|
||||
onClick: () => setVerificationClicked(true),
|
||||
}
|
||||
}
|
||||
type="warning"
|
||||
withRightButton
|
||||
infoTooltipText={
|
||||
blockExplorerTokenLink &&
|
||||
|
@ -109,6 +109,22 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-input-pair {
|
||||
.searchable-item-list {
|
||||
&__item--add-token {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__to {
|
||||
.searchable-item-list {
|
||||
&__item--add-token {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__open-to-dropdown {
|
||||
max-height: 194px;
|
||||
|
||||
|
@ -1,6 +1,10 @@
|
||||
import React from 'react';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
|
||||
import { renderWithProvider } from '../../../../test/jest';
|
||||
import {
|
||||
renderWithProvider,
|
||||
createSwapsMockStore,
|
||||
} from '../../../../test/jest';
|
||||
import DropdownInputPair from '.';
|
||||
|
||||
const createProps = (customProps = {}) => {
|
||||
@ -11,9 +15,11 @@ const createProps = (customProps = {}) => {
|
||||
|
||||
describe('DropdownInputPair', () => {
|
||||
it('renders the component with initial props', () => {
|
||||
const store = configureMockStore()(createSwapsMockStore());
|
||||
const props = createProps();
|
||||
const { getByPlaceholderText } = renderWithProvider(
|
||||
<DropdownInputPair {...props} />,
|
||||
store,
|
||||
);
|
||||
expect(getByPlaceholderText('0')).toBeInTheDocument();
|
||||
expect(
|
||||
|
@ -5,6 +5,7 @@ import React, {
|
||||
useContext,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { isEqual } from 'lodash';
|
||||
@ -12,6 +13,16 @@ import { I18nContext } from '../../../contexts/i18n';
|
||||
import SearchableItemList from '../searchable-item-list';
|
||||
import PulseLoader from '../../../components/ui/pulse-loader';
|
||||
import UrlIcon from '../../../components/ui/url-icon';
|
||||
import ActionableMessage from '../actionable-message';
|
||||
import ImportToken from '../import-token';
|
||||
import { useNewMetricEvent } from '../../../hooks/useMetricEvent';
|
||||
import {
|
||||
isHardwareWallet,
|
||||
getHardwareWalletType,
|
||||
getCurrentChainId,
|
||||
getRpcPrefsForCurrentProvider,
|
||||
} from '../../../selectors/selectors';
|
||||
import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/swaps';
|
||||
|
||||
export default function DropdownSearchList({
|
||||
searchListClassName,
|
||||
@ -31,10 +42,31 @@ export default function DropdownSearchList({
|
||||
hideRightLabels,
|
||||
hideItemIf,
|
||||
listContainerClassName,
|
||||
shouldSearchForImports,
|
||||
}) {
|
||||
const t = useContext(I18nContext);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isImportTokenModalOpen, setIsImportTokenModalOpen] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState(startingItem);
|
||||
const [tokenForImport, setTokenForImport] = useState(null);
|
||||
|
||||
const hardwareWalletUsed = useSelector(isHardwareWallet);
|
||||
const hardwareWalletType = useSelector(getHardwareWalletType);
|
||||
const chainId = useSelector(getCurrentChainId);
|
||||
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
|
||||
|
||||
const tokenImportedEvent = useNewMetricEvent({
|
||||
event: 'Token Imported',
|
||||
sensitiveProperties: {
|
||||
symbol: tokenForImport?.symbol,
|
||||
address: tokenForImport?.address,
|
||||
chain_id: chainId,
|
||||
is_hardware_wallet: hardwareWalletUsed,
|
||||
hardware_wallet_type: hardwareWalletType,
|
||||
},
|
||||
category: 'swaps',
|
||||
});
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
@ -49,6 +81,25 @@ export default function DropdownSearchList({
|
||||
[onSelect, close],
|
||||
);
|
||||
|
||||
const onOpenImportTokenModalClick = (item) => {
|
||||
setTokenForImport(item);
|
||||
setIsImportTokenModalOpen(true);
|
||||
};
|
||||
|
||||
const onImportTokenClick = () => {
|
||||
tokenImportedEvent();
|
||||
// Only when a user confirms import of a token, we add it and show it in a dropdown.
|
||||
onSelect?.(tokenForImport);
|
||||
setSelectedItem(tokenForImport);
|
||||
setTokenForImport(null);
|
||||
close();
|
||||
};
|
||||
|
||||
const onImportTokenCloseClick = () => {
|
||||
setIsImportTokenModalOpen(false);
|
||||
close();
|
||||
};
|
||||
|
||||
const onClickSelector = useCallback(() => {
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
@ -81,6 +132,34 @@ export default function DropdownSearchList({
|
||||
}
|
||||
};
|
||||
|
||||
const blockExplorerLink =
|
||||
rpcPrefs.blockExplorerUrl ??
|
||||
SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ??
|
||||
null;
|
||||
|
||||
const blockExplorerLabel = rpcPrefs.blockExplorerUrl
|
||||
? new URL(blockExplorerLink).hostname
|
||||
: t('etherscan');
|
||||
|
||||
const blockExplorerLinkClickedEvent = useNewMetricEvent({
|
||||
category: 'Swaps',
|
||||
event: 'Clicked Block Explorer Link',
|
||||
properties: {
|
||||
link_type: 'Token Tracker',
|
||||
action: 'Verify Contract Address',
|
||||
block_explorer_domain: blockExplorerLink
|
||||
? new URL(blockExplorerLink)?.hostname
|
||||
: '',
|
||||
},
|
||||
});
|
||||
|
||||
const importTokenProps = {
|
||||
onImportTokenCloseClick,
|
||||
onImportTokenClick,
|
||||
setIsImportTokenModalOpen,
|
||||
tokenForImport,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames('dropdown-search-list', className)}
|
||||
@ -88,6 +167,9 @@ export default function DropdownSearchList({
|
||||
onKeyUp={onKeyUp}
|
||||
tabIndex="0"
|
||||
>
|
||||
{tokenForImport && isImportTokenModalOpen && (
|
||||
<ImportToken {...importTokenProps} />
|
||||
)}
|
||||
{!isOpen && (
|
||||
<div
|
||||
className={classnames(
|
||||
@ -141,6 +223,32 @@ export default function DropdownSearchList({
|
||||
) : (
|
||||
<div className="dropdown-search-list__placeholder">
|
||||
{t('swapBuildQuotePlaceHolderText', [searchQuery])}
|
||||
<div
|
||||
tabIndex="0"
|
||||
className="searchable-item-list__item searchable-item-list__item--add-token"
|
||||
key="searchable-item-list-item-last"
|
||||
>
|
||||
<ActionableMessage
|
||||
message={
|
||||
blockExplorerLink &&
|
||||
t('addCustomTokenByContractAddress', [
|
||||
<a
|
||||
key="dropdown-search-list__etherscan-link"
|
||||
onClick={() => {
|
||||
blockExplorerLinkClickedEvent();
|
||||
global.platform.openTab({
|
||||
url: blockExplorerLink,
|
||||
});
|
||||
}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{blockExplorerLabel}
|
||||
</a>,
|
||||
])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -148,6 +256,7 @@ export default function DropdownSearchList({
|
||||
fuseSearchKeys={fuseSearchKeys}
|
||||
defaultToAll={defaultToAll}
|
||||
onClickItem={onClickItem}
|
||||
onOpenImportTokenModalClick={onOpenImportTokenModalClick}
|
||||
maxListItems={maxListItems}
|
||||
className={classnames(
|
||||
'dropdown-search-list__token-container',
|
||||
@ -159,6 +268,7 @@ export default function DropdownSearchList({
|
||||
hideRightLabels={hideRightLabels}
|
||||
hideItemIf={hideItemIf}
|
||||
listContainerClassName={listContainerClassName}
|
||||
shouldSearchForImports={shouldSearchForImports}
|
||||
/>
|
||||
<div
|
||||
className="dropdown-search-list__close-area"
|
||||
@ -197,4 +307,5 @@ DropdownSearchList.propTypes = {
|
||||
hideRightLabels: PropTypes.bool,
|
||||
hideItemIf: PropTypes.func,
|
||||
listContainerClassName: PropTypes.string,
|
||||
shouldSearchForImports: PropTypes.bool,
|
||||
};
|
||||
|
@ -1,6 +1,10 @@
|
||||
import React from 'react';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
|
||||
import { renderWithProvider } from '../../../../test/jest';
|
||||
import {
|
||||
renderWithProvider,
|
||||
createSwapsMockStore,
|
||||
} from '../../../../test/jest';
|
||||
import DropdownSearchList from '.';
|
||||
|
||||
const createProps = (customProps = {}) => {
|
||||
@ -15,9 +19,11 @@ const createProps = (customProps = {}) => {
|
||||
|
||||
describe('DropdownSearchList', () => {
|
||||
it('renders the component with initial props', () => {
|
||||
const store = configureMockStore()(createSwapsMockStore());
|
||||
const props = createProps();
|
||||
const { container, getByText } = renderWithProvider(
|
||||
<DropdownSearchList {...props} />,
|
||||
store,
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
expect(getByText('symbol')).toBeInTheDocument();
|
||||
|
@ -63,7 +63,7 @@
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
height: 60px;
|
||||
|
||||
i {
|
||||
@ -128,12 +128,16 @@
|
||||
color: $Grey-500;
|
||||
min-height: 300px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
z-index: 1002;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
min-height: 194px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.searchable-item-list__item--add-token {
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__loading-item {
|
||||
|
89
ui/pages/swaps/import-token/import-token.js
Normal file
89
ui/pages/swaps/import-token/import-token.js
Normal file
@ -0,0 +1,89 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { I18nContext } from '../../../contexts/i18n';
|
||||
import UrlIcon from '../../../components/ui/url-icon';
|
||||
import Popover from '../../../components/ui/popover';
|
||||
import Button from '../../../components/ui/button';
|
||||
import Box from '../../../components/ui/box';
|
||||
import Typography from '../../../components/ui/typography';
|
||||
import ActionableMessage from '../actionable-message';
|
||||
import {
|
||||
TYPOGRAPHY,
|
||||
FONT_WEIGHT,
|
||||
ALIGN_ITEMS,
|
||||
DISPLAY,
|
||||
} from '../../../helpers/constants/design-system';
|
||||
|
||||
export default function ImportToken({
|
||||
onImportTokenCloseClick,
|
||||
onImportTokenClick,
|
||||
setIsImportTokenModalOpen,
|
||||
tokenForImport,
|
||||
}) {
|
||||
const t = useContext(I18nContext);
|
||||
const ImportTokenModalFooter = (
|
||||
<>
|
||||
<Button
|
||||
type="secondary"
|
||||
className="page-container__footer-button"
|
||||
onClick={onImportTokenCloseClick}
|
||||
rounded
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="confirm"
|
||||
className="page-container__footer-button"
|
||||
onClick={onImportTokenClick}
|
||||
rounded
|
||||
>
|
||||
{t('import')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
title={t('importTokenQuestion')}
|
||||
onClose={() => setIsImportTokenModalOpen(false)}
|
||||
footer={ImportTokenModalFooter}
|
||||
>
|
||||
<Box
|
||||
padding={[0, 6, 4, 6]}
|
||||
alignItems={ALIGN_ITEMS.CENTER}
|
||||
display={DISPLAY.FLEX}
|
||||
className="import-token"
|
||||
>
|
||||
<ActionableMessage type="danger" message={t('importTokenWarning')} />
|
||||
<UrlIcon
|
||||
url={tokenForImport.iconUrl}
|
||||
className="import-token__token-icon"
|
||||
fallbackClassName="import-token__token-icon"
|
||||
name={tokenForImport.symbol}
|
||||
/>
|
||||
<Typography
|
||||
ariant={TYPOGRAPHY.H4}
|
||||
fontWeight={FONT_WEIGHT.BOLD}
|
||||
boxProps={{ marginTop: 2, marginBottom: 3 }}
|
||||
>
|
||||
{tokenForImport.name}
|
||||
</Typography>
|
||||
<Typography variant={TYPOGRAPHY.H6}>{t('contract')}:</Typography>
|
||||
<Typography
|
||||
className="import-token__contract-address"
|
||||
variant={TYPOGRAPHY.H7}
|
||||
boxProps={{ marginBottom: 6 }}
|
||||
>
|
||||
{tokenForImport.address}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
ImportToken.propTypes = {
|
||||
onImportTokenCloseClick: PropTypes.func,
|
||||
onImportTokenClick: PropTypes.func,
|
||||
setIsImportTokenModalOpen: PropTypes.func,
|
||||
tokenForImport: PropTypes.object,
|
||||
};
|
27
ui/pages/swaps/import-token/import-token.test.js
Normal file
27
ui/pages/swaps/import-token/import-token.test.js
Normal file
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
import { renderWithProvider } from '../../../../test/jest';
|
||||
import ImportToken from '.';
|
||||
|
||||
const createProps = (customProps = {}) => {
|
||||
return {
|
||||
onImportTokenCloseClick: jest.fn(),
|
||||
onImportTokenClick: jest.fn(),
|
||||
setIsImportTokenModalOpen: jest.fn(),
|
||||
tokenForImport: {
|
||||
symbol: 'POS',
|
||||
name: 'PoSToken',
|
||||
address: '0xee609fe292128cad03b786dbb9bc2634ccdbe7fc',
|
||||
},
|
||||
...customProps,
|
||||
};
|
||||
};
|
||||
|
||||
describe('ImportToken', () => {
|
||||
it('renders the component with initial props', () => {
|
||||
const props = createProps();
|
||||
const { getByText } = renderWithProvider(<ImportToken {...props} />);
|
||||
expect(getByText(props.tokenForImport.name)).toBeInTheDocument();
|
||||
expect(getByText(props.tokenForImport.address)).toBeInTheDocument();
|
||||
});
|
||||
});
|
1
ui/pages/swaps/import-token/index.js
Normal file
1
ui/pages/swaps/import-token/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './import-token';
|
30
ui/pages/swaps/import-token/index.scss
Normal file
30
ui/pages/swaps/import-token/index.scss
Normal file
@ -0,0 +1,30 @@
|
||||
.import-token {
|
||||
flex-direction: column;
|
||||
|
||||
.actionable-message {
|
||||
margin-top: 0;
|
||||
|
||||
&--danger {
|
||||
border-color: $Red-300;
|
||||
background: $Red-000;
|
||||
}
|
||||
|
||||
&__message {
|
||||
color: $Black-100;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
&__contract-address {
|
||||
border-radius: 8px;
|
||||
background-color: $Grey-000;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
&__token-icon {
|
||||
font-size: $font-size-h2;
|
||||
margin-top: 24px;
|
||||
width: 69px;
|
||||
height: 69px;
|
||||
}
|
||||
}
|
@ -14,6 +14,7 @@
|
||||
@import 'slippage-buttons/index';
|
||||
@import 'swaps-footer/index';
|
||||
@import 'view-quote/index';
|
||||
@import 'import-token/index';
|
||||
|
||||
.swaps {
|
||||
display: flex;
|
||||
|
@ -43,7 +43,7 @@
|
||||
&__list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__item {
|
||||
@ -63,7 +63,7 @@
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: 1px solid $Grey-100;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
@ -80,6 +80,38 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&--add-token {
|
||||
min-height: auto;
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.actionable-message {
|
||||
margin: 0;
|
||||
|
||||
&__message {
|
||||
text-align: left;
|
||||
color: $Black-100;
|
||||
}
|
||||
|
||||
a {
|
||||
pointer-events: auto;
|
||||
color: #037dd6;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@include H7;
|
||||
|
||||
width: auto;
|
||||
padding: 7px 11px;
|
||||
}
|
||||
|
||||
> img {
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
@ -1,12 +1,23 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import Identicon from '../../../../components/ui/identicon';
|
||||
import UrlIcon from '../../../../components/ui/url-icon';
|
||||
import Button from '../../../../components/ui/button';
|
||||
import ActionableMessage from '../../actionable-message';
|
||||
import { I18nContext } from '../../../../contexts/i18n';
|
||||
import {
|
||||
getCurrentChainId,
|
||||
getRpcPrefsForCurrentProvider,
|
||||
} from '../../../../selectors';
|
||||
import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../../shared/constants/swaps';
|
||||
import { useNewMetricEvent } from '../../../../hooks/useMetricEvent';
|
||||
|
||||
export default function ItemList({
|
||||
results = [],
|
||||
onClickItem,
|
||||
onOpenImportTokenModalClick,
|
||||
Placeholder,
|
||||
listTitle,
|
||||
maxListItems = 6,
|
||||
@ -16,6 +27,32 @@ export default function ItemList({
|
||||
hideItemIf,
|
||||
listContainerClassName,
|
||||
}) {
|
||||
const t = useContext(I18nContext);
|
||||
const chainId = useSelector(getCurrentChainId);
|
||||
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
|
||||
const blockExplorerLink =
|
||||
rpcPrefs.blockExplorerUrl ??
|
||||
SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ??
|
||||
null;
|
||||
|
||||
const blockExplorerLabel = rpcPrefs.blockExplorerUrl
|
||||
? new URL(blockExplorerLink).hostname
|
||||
: t('etherscan');
|
||||
|
||||
const blockExplorerLinkClickedEvent = useNewMetricEvent({
|
||||
category: 'Swaps',
|
||||
event: 'Clicked Block Explorer Link',
|
||||
properties: {
|
||||
link_type: 'Token Tracker',
|
||||
action: 'Verify Contract Address',
|
||||
block_explorer_domain: blockExplorerLink
|
||||
? new URL(blockExplorerLink)?.hostname
|
||||
: '',
|
||||
},
|
||||
});
|
||||
|
||||
// If there is a token for import based on a contract address, it's the only one in the list.
|
||||
const hasTokenForImport = results.length === 1 && results[0].notImported;
|
||||
return results.length === 0 ? (
|
||||
Placeholder && <Placeholder searchQuery={searchQuery} />
|
||||
) : (
|
||||
@ -35,7 +72,13 @@ export default function ItemList({
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = () => onClickItem?.(result);
|
||||
const onClick = () => {
|
||||
if (result.notImported) {
|
||||
onOpenImportTokenModalClick(result);
|
||||
} else {
|
||||
onClickItem?.(result);
|
||||
}
|
||||
};
|
||||
const {
|
||||
iconUrl,
|
||||
identiconAddress,
|
||||
@ -96,9 +139,42 @@ export default function ItemList({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{result.notImported && (
|
||||
<Button type="confirm" onClick={onClick} rounded>
|
||||
{t('import')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!hasTokenForImport && (
|
||||
<div
|
||||
tabIndex="0"
|
||||
className="searchable-item-list__item searchable-item-list__item--add-token"
|
||||
key="searchable-item-list-item-last"
|
||||
>
|
||||
<ActionableMessage
|
||||
message={
|
||||
blockExplorerLink &&
|
||||
t('addCustomTokenByContractAddress', [
|
||||
<a
|
||||
key="searchable-item-list__etherscan-link"
|
||||
onClick={() => {
|
||||
blockExplorerLinkClickedEvent();
|
||||
global.platform.openTab({
|
||||
url: blockExplorerLink,
|
||||
});
|
||||
}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{blockExplorerLabel}
|
||||
</a>,
|
||||
])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -117,6 +193,7 @@ ItemList.propTypes = {
|
||||
}),
|
||||
),
|
||||
onClickItem: PropTypes.func,
|
||||
onOpenImportTokenModalClick: PropTypes.func,
|
||||
Placeholder: PropTypes.func,
|
||||
listTitle: PropTypes.string,
|
||||
maxListItems: PropTypes.number,
|
||||
|
@ -1,9 +1,14 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import Fuse from 'fuse.js';
|
||||
import log from 'loglevel';
|
||||
import InputAdornment from '@material-ui/core/InputAdornment';
|
||||
import TextField from '../../../../components/ui/text-field';
|
||||
import { usePrevious } from '../../../../hooks/usePrevious';
|
||||
import { isValidHexAddress } from '../../../../../shared/modules/hexstring-utils';
|
||||
import { fetchToken } from '../../swaps.util';
|
||||
import { getCurrentChainId } from '../../../../selectors/selectors';
|
||||
|
||||
const renderAdornment = () => (
|
||||
<InputAdornment position="start" style={{ marginRight: '12px' }}>
|
||||
@ -18,17 +23,53 @@ export default function ListItemSearch({
|
||||
fuseSearchKeys,
|
||||
searchPlaceholderText,
|
||||
defaultToAll,
|
||||
shouldSearchForImports,
|
||||
}) {
|
||||
const fuseRef = useRef();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const chainId = useSelector(getCurrentChainId);
|
||||
|
||||
const handleSearch = (newSearchQuery) => {
|
||||
setSearchQuery(newSearchQuery);
|
||||
/**
|
||||
* Search a custom token for import based on a contract address.
|
||||
* @param {String} contractAddress
|
||||
*/
|
||||
const handleSearchTokenForImport = async (contractAddress) => {
|
||||
setSearchQuery(contractAddress);
|
||||
try {
|
||||
const token = await fetchToken(contractAddress, chainId);
|
||||
if (token) {
|
||||
token.primaryLabel = token.symbol;
|
||||
token.secondaryLabel = token.name;
|
||||
token.notImported = true;
|
||||
onSearch({
|
||||
searchQuery: contractAddress,
|
||||
results: [token],
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('Token not found, show 0 results.', e);
|
||||
}
|
||||
onSearch({
|
||||
searchQuery: contractAddress,
|
||||
results: [], // No token for import found.
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = async (newSearchQuery) => {
|
||||
const trimmedNewSearchQuery = newSearchQuery.trim();
|
||||
const validHexAddress = isValidHexAddress(trimmedNewSearchQuery);
|
||||
const fuseSearchResult = fuseRef.current.search(newSearchQuery);
|
||||
const results =
|
||||
defaultToAll && newSearchQuery === '' ? listToSearch : fuseSearchResult;
|
||||
if (shouldSearchForImports && results.length === 0 && validHexAddress) {
|
||||
await handleSearchTokenForImport(trimmedNewSearchQuery);
|
||||
return;
|
||||
}
|
||||
setSearchQuery(newSearchQuery);
|
||||
onSearch({
|
||||
searchQuery: newSearchQuery,
|
||||
results:
|
||||
defaultToAll && newSearchQuery === '' ? listToSearch : fuseSearchResult,
|
||||
results,
|
||||
});
|
||||
};
|
||||
|
||||
@ -83,4 +124,5 @@ ListItemSearch.propTypes = {
|
||||
fuseSearchKeys: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
searchPlaceholderText: PropTypes.string,
|
||||
defaultToAll: PropTypes.bool,
|
||||
shouldSearchForImports: PropTypes.bool,
|
||||
};
|
||||
|
@ -12,11 +12,13 @@ export default function SearchableItemList({
|
||||
listTitle,
|
||||
maxListItems,
|
||||
onClickItem,
|
||||
onOpenImportTokenModalClick,
|
||||
Placeholder,
|
||||
searchPlaceholderText,
|
||||
hideRightLabels,
|
||||
hideItemIf,
|
||||
listContainerClassName,
|
||||
shouldSearchForImports,
|
||||
}) {
|
||||
const itemListRef = useRef();
|
||||
|
||||
@ -38,11 +40,13 @@ export default function SearchableItemList({
|
||||
error={itemSelectorError}
|
||||
searchPlaceholderText={searchPlaceholderText}
|
||||
defaultToAll={defaultToAll}
|
||||
shouldSearchForImports={shouldSearchForImports}
|
||||
/>
|
||||
<ItemList
|
||||
searchQuery={searchQuery}
|
||||
results={results}
|
||||
onClickItem={onClickItem}
|
||||
onOpenImportTokenModalClick={onOpenImportTokenModalClick}
|
||||
Placeholder={Placeholder}
|
||||
listTitle={listTitle}
|
||||
maxListItems={maxListItems}
|
||||
@ -59,6 +63,7 @@ SearchableItemList.propTypes = {
|
||||
itemSelectorError: PropTypes.string,
|
||||
itemsToSearch: PropTypes.array,
|
||||
onClickItem: PropTypes.func,
|
||||
onOpenImportTokenModalClick: PropTypes.func,
|
||||
Placeholder: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
searchPlaceholderText: PropTypes.string,
|
||||
@ -74,4 +79,5 @@ SearchableItemList.propTypes = {
|
||||
hideRightLabels: PropTypes.bool,
|
||||
hideItemIf: PropTypes.func,
|
||||
listContainerClassName: PropTypes.string,
|
||||
shouldSearchForImports: PropTypes.bool,
|
||||
};
|
||||
|
@ -1,6 +1,10 @@
|
||||
import React from 'react';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
|
||||
import { renderWithProvider } from '../../../../test/jest';
|
||||
import {
|
||||
renderWithProvider,
|
||||
createSwapsMockStore,
|
||||
} from '../../../../test/jest';
|
||||
import SearchableItemList from '.';
|
||||
|
||||
const createProps = (customProps = {}) => {
|
||||
@ -37,8 +41,12 @@ const createProps = (customProps = {}) => {
|
||||
|
||||
describe('SearchableItemList', () => {
|
||||
it('renders the component with initial props', () => {
|
||||
const store = configureMockStore()(createSwapsMockStore());
|
||||
const props = createProps();
|
||||
const { getByText } = renderWithProvider(<SearchableItemList {...props} />);
|
||||
const { getByText } = renderWithProvider(
|
||||
<SearchableItemList {...props} />,
|
||||
store,
|
||||
);
|
||||
expect(getByText(props.listTitle)).toBeInTheDocument();
|
||||
expect(getByText(props.itemsToSearch[0].primaryLabel)).toBeInTheDocument();
|
||||
expect(
|
||||
|
@ -10,7 +10,6 @@
|
||||
margin-bottom: 0;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
cursor: pointer;
|
||||
background: unset;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
@ -47,6 +47,8 @@ const getBaseApi = function (type, chainId = MAINNET_CHAIN_ID) {
|
||||
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/trades?`;
|
||||
case 'tokens':
|
||||
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/tokens`;
|
||||
case 'token':
|
||||
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/token`;
|
||||
case 'topAssets':
|
||||
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/topAssets`;
|
||||
case 'featureFlag':
|
||||
@ -290,10 +292,20 @@ export async function fetchTradesInfo(
|
||||
return newQuotes;
|
||||
}
|
||||
|
||||
export async function fetchToken(contractAddress, chainId) {
|
||||
const tokenUrl = getBaseApi('token', chainId);
|
||||
const token = await fetchWithCache(
|
||||
`${tokenUrl}?address=${contractAddress}`,
|
||||
{ method: 'GET' },
|
||||
{ cacheRefreshTime: CACHE_REFRESH_FIVE_MINUTES },
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function fetchTokens(chainId) {
|
||||
const tokenUrl = getBaseApi('tokens', chainId);
|
||||
const tokensUrl = getBaseApi('tokens', chainId);
|
||||
const tokens = await fetchWithCache(
|
||||
tokenUrl,
|
||||
tokensUrl,
|
||||
{ method: 'GET' },
|
||||
{ cacheRefreshTime: CACHE_REFRESH_FIVE_MINUTES },
|
||||
);
|
||||
@ -301,7 +313,7 @@ export async function fetchTokens(chainId) {
|
||||
SWAPS_CHAINID_DEFAULT_TOKEN_MAP[chainId],
|
||||
...tokens.filter((token) => {
|
||||
return (
|
||||
validateData(TOKEN_VALIDATORS, token, tokenUrl) &&
|
||||
validateData(TOKEN_VALIDATORS, token, tokensUrl) &&
|
||||
!(
|
||||
isSwapsDefaultTokenSymbol(token.symbol, chainId) ||
|
||||
isSwapsDefaultTokenAddress(token.address, chainId)
|
||||
|
Loading…
Reference in New Issue
Block a user