1
0
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:
Daniel 2021-06-03 18:08:37 +02:00 committed by GitHub
parent 6700c460fd
commit c3b79bb358
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 562 additions and 51 deletions

View File

@ -52,6 +52,10 @@
"addContact": {
"message": "Add contact"
},
"addCustomTokenByContractAddress": {
"message": "Cant 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"
},

View File

@ -6,7 +6,7 @@ module.exports = {
coverageThreshold: {
global: {
branches: 32.75,
functions: 43.31,
functions: 42.9,
lines: 43.12,
statements: 43.67,
},

View File

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

View File

@ -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]) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();

View File

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

View 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,
};

View 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();
});
});

View File

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

View 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;
}
}

View File

@ -14,6 +14,7 @@
@import 'slippage-buttons/index';
@import 'swaps-footer/index';
@import 'view-quote/index';
@import 'import-token/index';
.swaps {
display: flex;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,6 @@
margin-bottom: 0;
margin-left: auto;
margin-right: auto;
cursor: pointer;
background: unset;
margin-bottom: 8px;
}

View File

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