mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-22 09:57:02 +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": {
|
"addContact": {
|
||||||
"message": "Add contact"
|
"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": {
|
"addEthereumChainConfirmationDescription": {
|
||||||
"message": "This will allow this network to be used within MetaMask."
|
"message": "This will allow this network to be used within MetaMask."
|
||||||
},
|
},
|
||||||
@ -410,6 +414,9 @@
|
|||||||
"continueToWyre": {
|
"continueToWyre": {
|
||||||
"message": "Continue to Wyre"
|
"message": "Continue to Wyre"
|
||||||
},
|
},
|
||||||
|
"contract": {
|
||||||
|
"message": "Contract"
|
||||||
|
},
|
||||||
"contractAddressError": {
|
"contractAddressError": {
|
||||||
"message": "You are sending tokens to the token's contract address. This may result in the loss of these tokens."
|
"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",
|
"message": "or $1",
|
||||||
"description": "$1 represents the text from `importAccountLinkText` as a link"
|
"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": {
|
"importWallet": {
|
||||||
"message": "Import wallet"
|
"message": "Import wallet"
|
||||||
},
|
},
|
||||||
@ -2066,13 +2079,13 @@
|
|||||||
"message": "Swap $1 to $2",
|
"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."
|
"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": {
|
"swapTokenVerificationMessage": {
|
||||||
"message": "Always confirm the token address on $1.",
|
"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."
|
"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": {
|
"swapTokenVerificationOnlyOneSource": {
|
||||||
"message": "Only verified on 1 source."
|
"message": "Only verified on 1 source."
|
||||||
},
|
},
|
||||||
@ -2358,6 +2371,10 @@
|
|||||||
"message": "Verify this token on $1",
|
"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\""
|
"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": {
|
"viewAccount": {
|
||||||
"message": "View Account"
|
"message": "View Account"
|
||||||
},
|
},
|
||||||
|
@ -6,7 +6,7 @@ module.exports = {
|
|||||||
coverageThreshold: {
|
coverageThreshold: {
|
||||||
global: {
|
global: {
|
||||||
branches: 32.75,
|
branches: 32.75,
|
||||||
functions: 43.31,
|
functions: 42.9,
|
||||||
lines: 43.12,
|
lines: 43.12,
|
||||||
statements: 43.67,
|
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 SWAPS_TESTNET_HOST = 'https://metaswap-api.airswap-dev.codefi.network';
|
||||||
|
|
||||||
const BSC_DEFAULT_BLOCK_EXPLORER_URL = 'https://bscscan.com/';
|
const BSC_DEFAULT_BLOCK_EXPLORER_URL = 'https://bscscan.com/';
|
||||||
|
const MAINNET_DEFAULT_BLOCK_EXPLORER_URL = 'https://etherscan.io/';
|
||||||
|
|
||||||
export const ALLOWED_SWAPS_CHAIN_IDS = {
|
export const ALLOWED_SWAPS_CHAIN_IDS = {
|
||||||
[MAINNET_CHAIN_ID]: true,
|
[MAINNET_CHAIN_ID]: true,
|
||||||
@ -90,4 +91,5 @@ export const SWAPS_CHAINID_DEFAULT_TOKEN_MAP = {
|
|||||||
|
|
||||||
export const SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP = {
|
export const SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP = {
|
||||||
[BSC_CHAIN_ID]: BSC_DEFAULT_BLOCK_EXPLORER_URL,
|
[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 { useSelector } from 'react-redux';
|
||||||
import contractMap from '@metamask/contract-metadata';
|
import contractMap from '@metamask/contract-metadata';
|
||||||
import BigNumber from 'bignumber.js';
|
import BigNumber from 'bignumber.js';
|
||||||
import { isEqual, shuffle } from 'lodash';
|
import { isEqual, shuffle, uniqBy } from 'lodash';
|
||||||
import { getTokenFiatAmount } from '../helpers/utils/token-util';
|
import { getTokenFiatAmount } from '../helpers/utils/token-util';
|
||||||
import {
|
import {
|
||||||
getTokenExchangeRates,
|
getTokenExchangeRates,
|
||||||
@ -119,7 +119,12 @@ export function useTokensToSearch({ usersTokens = [], topTokens = {} }) {
|
|||||||
others: [],
|
others: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
memoizedTokensToSearch.forEach((token) => {
|
const memoizedSwapsAndUserTokensWithoutDuplicities = uniqBy(
|
||||||
|
[...memoizedTokensToSearch, ...memoizedUsersToken],
|
||||||
|
'address',
|
||||||
|
);
|
||||||
|
|
||||||
|
memoizedSwapsAndUserTokensWithoutDuplicities.forEach((token) => {
|
||||||
const renderableDataToken = getRenderableTokenData(
|
const renderableDataToken = getRenderableTokenData(
|
||||||
{ ...usersTokensAddressMap[token.address], ...token },
|
{ ...usersTokensAddressMap[token.address], ...token },
|
||||||
tokenConversionRates,
|
tokenConversionRates,
|
||||||
@ -129,8 +134,7 @@ export function useTokensToSearch({ usersTokens = [], topTokens = {} }) {
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
isSwapsDefaultTokenSymbol(renderableDataToken.symbol, chainId) ||
|
isSwapsDefaultTokenSymbol(renderableDataToken.symbol, chainId) ||
|
||||||
(usersTokensAddressMap[token.address] &&
|
usersTokensAddressMap[token.address]
|
||||||
Number(renderableDataToken.balance ?? 0) !== 0)
|
|
||||||
) {
|
) {
|
||||||
tokensToSearchBuckets.owned.push(renderableDataToken);
|
tokensToSearchBuckets.owned.push(renderableDataToken);
|
||||||
} else if (memoizedTopTokens[token.address]) {
|
} else if (memoizedTopTokens[token.address]) {
|
||||||
|
@ -51,12 +51,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--danger {
|
&--danger {
|
||||||
background: $Red-100;
|
background: $Red-000;
|
||||||
border: 1px solid $Red-500;
|
border: 1px solid $Red-300;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
|
||||||
.actionable-message__message {
|
.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,
|
store,
|
||||||
);
|
);
|
||||||
expect(getByText('Processing')).toBeInTheDocument();
|
expect(getByText('Processing')).toBeInTheDocument();
|
||||||
expect(getByText('View on Etherscan')).toBeInTheDocument();
|
expect(getByText('ETH')).toBeInTheDocument();
|
||||||
expect(getByText('View in activity')).toBeInTheDocument();
|
expect(getByText('View in activity')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
document.querySelector('.awaiting-swap__main-descrption'),
|
document.querySelector('.awaiting-swap__main-descrption'),
|
||||||
|
@ -333,6 +333,38 @@ export default function BuildQuote({
|
|||||||
dispatch(resetSwapsPostFetchState());
|
dispatch(resetSwapsPostFetchState());
|
||||||
}, [dispatch]);
|
}, [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 (
|
return (
|
||||||
<div className="build-quote">
|
<div className="build-quote">
|
||||||
<div className="build-quote__content">
|
<div className="build-quote__content">
|
||||||
@ -434,37 +466,21 @@ export default function BuildQuote({
|
|||||||
listContainerClassName="build-quote__open-to-dropdown"
|
listContainerClassName="build-quote__open-to-dropdown"
|
||||||
hideRightLabels
|
hideRightLabels
|
||||||
defaultToAll
|
defaultToAll
|
||||||
|
shouldSearchForImports
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{toTokenIsNotDefault &&
|
{toTokenIsNotDefault &&
|
||||||
(occurances < 2 ? (
|
(occurances < 2 ? (
|
||||||
<ActionableMessage
|
<ActionableMessage
|
||||||
|
type={occurances === 1 ? 'warning' : 'danger'}
|
||||||
message={
|
message={
|
||||||
<div className="build-quote__token-verification-warning-message">
|
<div className="build-quote__token-verification-warning-message">
|
||||||
<div className="build-quote__bold">
|
<div className="build-quote__bold">
|
||||||
{occurances === 1
|
{occurances === 1
|
||||||
? t('swapTokenVerificationOnlyOneSource')
|
? t('swapTokenVerificationOnlyOneSource')
|
||||||
: t('swapTokenVerificationNoSource')}
|
: t('swapTokenVerificationAddedManually')}
|
||||||
</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>,
|
|
||||||
])}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>{tokenVerificationDescription}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
primaryAction={
|
primaryAction={
|
||||||
@ -475,7 +491,6 @@ export default function BuildQuote({
|
|||||||
onClick: () => setVerificationClicked(true),
|
onClick: () => setVerificationClicked(true),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
type="warning"
|
|
||||||
withRightButton
|
withRightButton
|
||||||
infoTooltipText={
|
infoTooltipText={
|
||||||
blockExplorerTokenLink &&
|
blockExplorerTokenLink &&
|
||||||
|
@ -109,6 +109,22 @@
|
|||||||
width: 100%;
|
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 {
|
&__open-to-dropdown {
|
||||||
max-height: 194px;
|
max-height: 194px;
|
||||||
|
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import configureMockStore from 'redux-mock-store';
|
||||||
|
|
||||||
import { renderWithProvider } from '../../../../test/jest';
|
import {
|
||||||
|
renderWithProvider,
|
||||||
|
createSwapsMockStore,
|
||||||
|
} from '../../../../test/jest';
|
||||||
import DropdownInputPair from '.';
|
import DropdownInputPair from '.';
|
||||||
|
|
||||||
const createProps = (customProps = {}) => {
|
const createProps = (customProps = {}) => {
|
||||||
@ -11,9 +15,11 @@ const createProps = (customProps = {}) => {
|
|||||||
|
|
||||||
describe('DropdownInputPair', () => {
|
describe('DropdownInputPair', () => {
|
||||||
it('renders the component with initial props', () => {
|
it('renders the component with initial props', () => {
|
||||||
|
const store = configureMockStore()(createSwapsMockStore());
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
const { getByPlaceholderText } = renderWithProvider(
|
const { getByPlaceholderText } = renderWithProvider(
|
||||||
<DropdownInputPair {...props} />,
|
<DropdownInputPair {...props} />,
|
||||||
|
store,
|
||||||
);
|
);
|
||||||
expect(getByPlaceholderText('0')).toBeInTheDocument();
|
expect(getByPlaceholderText('0')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
|
@ -5,6 +5,7 @@ import React, {
|
|||||||
useContext,
|
useContext,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
@ -12,6 +13,16 @@ import { I18nContext } from '../../../contexts/i18n';
|
|||||||
import SearchableItemList from '../searchable-item-list';
|
import SearchableItemList from '../searchable-item-list';
|
||||||
import PulseLoader from '../../../components/ui/pulse-loader';
|
import PulseLoader from '../../../components/ui/pulse-loader';
|
||||||
import UrlIcon from '../../../components/ui/url-icon';
|
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({
|
export default function DropdownSearchList({
|
||||||
searchListClassName,
|
searchListClassName,
|
||||||
@ -31,10 +42,31 @@ export default function DropdownSearchList({
|
|||||||
hideRightLabels,
|
hideRightLabels,
|
||||||
hideItemIf,
|
hideItemIf,
|
||||||
listContainerClassName,
|
listContainerClassName,
|
||||||
|
shouldSearchForImports,
|
||||||
}) {
|
}) {
|
||||||
const t = useContext(I18nContext);
|
const t = useContext(I18nContext);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isImportTokenModalOpen, setIsImportTokenModalOpen] = useState(false);
|
||||||
const [selectedItem, setSelectedItem] = useState(startingItem);
|
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(() => {
|
const close = useCallback(() => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
onClose?.();
|
onClose?.();
|
||||||
@ -49,6 +81,25 @@ export default function DropdownSearchList({
|
|||||||
[onSelect, close],
|
[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(() => {
|
const onClickSelector = useCallback(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setIsOpen(true);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classnames('dropdown-search-list', className)}
|
className={classnames('dropdown-search-list', className)}
|
||||||
@ -88,6 +167,9 @@ export default function DropdownSearchList({
|
|||||||
onKeyUp={onKeyUp}
|
onKeyUp={onKeyUp}
|
||||||
tabIndex="0"
|
tabIndex="0"
|
||||||
>
|
>
|
||||||
|
{tokenForImport && isImportTokenModalOpen && (
|
||||||
|
<ImportToken {...importTokenProps} />
|
||||||
|
)}
|
||||||
{!isOpen && (
|
{!isOpen && (
|
||||||
<div
|
<div
|
||||||
className={classnames(
|
className={classnames(
|
||||||
@ -141,6 +223,32 @@ export default function DropdownSearchList({
|
|||||||
) : (
|
) : (
|
||||||
<div className="dropdown-search-list__placeholder">
|
<div className="dropdown-search-list__placeholder">
|
||||||
{t('swapBuildQuotePlaceHolderText', [searchQuery])}
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -148,6 +256,7 @@ export default function DropdownSearchList({
|
|||||||
fuseSearchKeys={fuseSearchKeys}
|
fuseSearchKeys={fuseSearchKeys}
|
||||||
defaultToAll={defaultToAll}
|
defaultToAll={defaultToAll}
|
||||||
onClickItem={onClickItem}
|
onClickItem={onClickItem}
|
||||||
|
onOpenImportTokenModalClick={onOpenImportTokenModalClick}
|
||||||
maxListItems={maxListItems}
|
maxListItems={maxListItems}
|
||||||
className={classnames(
|
className={classnames(
|
||||||
'dropdown-search-list__token-container',
|
'dropdown-search-list__token-container',
|
||||||
@ -159,6 +268,7 @@ export default function DropdownSearchList({
|
|||||||
hideRightLabels={hideRightLabels}
|
hideRightLabels={hideRightLabels}
|
||||||
hideItemIf={hideItemIf}
|
hideItemIf={hideItemIf}
|
||||||
listContainerClassName={listContainerClassName}
|
listContainerClassName={listContainerClassName}
|
||||||
|
shouldSearchForImports={shouldSearchForImports}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="dropdown-search-list__close-area"
|
className="dropdown-search-list__close-area"
|
||||||
@ -197,4 +307,5 @@ DropdownSearchList.propTypes = {
|
|||||||
hideRightLabels: PropTypes.bool,
|
hideRightLabels: PropTypes.bool,
|
||||||
hideItemIf: PropTypes.func,
|
hideItemIf: PropTypes.func,
|
||||||
listContainerClassName: PropTypes.string,
|
listContainerClassName: PropTypes.string,
|
||||||
|
shouldSearchForImports: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import configureMockStore from 'redux-mock-store';
|
||||||
|
|
||||||
import { renderWithProvider } from '../../../../test/jest';
|
import {
|
||||||
|
renderWithProvider,
|
||||||
|
createSwapsMockStore,
|
||||||
|
} from '../../../../test/jest';
|
||||||
import DropdownSearchList from '.';
|
import DropdownSearchList from '.';
|
||||||
|
|
||||||
const createProps = (customProps = {}) => {
|
const createProps = (customProps = {}) => {
|
||||||
@ -15,9 +19,11 @@ const createProps = (customProps = {}) => {
|
|||||||
|
|
||||||
describe('DropdownSearchList', () => {
|
describe('DropdownSearchList', () => {
|
||||||
it('renders the component with initial props', () => {
|
it('renders the component with initial props', () => {
|
||||||
|
const store = configureMockStore()(createSwapsMockStore());
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
const { container, getByText } = renderWithProvider(
|
const { container, getByText } = renderWithProvider(
|
||||||
<DropdownSearchList {...props} />,
|
<DropdownSearchList {...props} />,
|
||||||
|
store,
|
||||||
);
|
);
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
expect(getByText('symbol')).toBeInTheDocument();
|
expect(getByText('symbol')).toBeInTheDocument();
|
||||||
|
@ -63,7 +63,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
flex: 1;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
|
|
||||||
i {
|
i {
|
||||||
@ -128,12 +128,16 @@
|
|||||||
color: $Grey-500;
|
color: $Grey-500;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1002;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
min-height: 194px;
|
min-height: 194px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
.searchable-item-list__item--add-token {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__loading-item {
|
&__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 'slippage-buttons/index';
|
||||||
@import 'swaps-footer/index';
|
@import 'swaps-footer/index';
|
||||||
@import 'view-quote/index';
|
@import 'view-quote/index';
|
||||||
|
@import 'import-token/index';
|
||||||
|
|
||||||
.swaps {
|
.swaps {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -43,7 +43,7 @@
|
|||||||
&__list-container {
|
&__list-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: scroll;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__item {
|
&__item {
|
||||||
@ -63,7 +63,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:last-of-type {
|
&:last-of-type {
|
||||||
border-bottom: 1px solid $Grey-100;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
@ -80,6 +80,38 @@
|
|||||||
pointer-events: none;
|
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 {
|
> img {
|
||||||
margin-top: -2px;
|
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 PropTypes from 'prop-types';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import Identicon from '../../../../components/ui/identicon';
|
import Identicon from '../../../../components/ui/identicon';
|
||||||
import UrlIcon from '../../../../components/ui/url-icon';
|
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({
|
export default function ItemList({
|
||||||
results = [],
|
results = [],
|
||||||
onClickItem,
|
onClickItem,
|
||||||
|
onOpenImportTokenModalClick,
|
||||||
Placeholder,
|
Placeholder,
|
||||||
listTitle,
|
listTitle,
|
||||||
maxListItems = 6,
|
maxListItems = 6,
|
||||||
@ -16,6 +27,32 @@ export default function ItemList({
|
|||||||
hideItemIf,
|
hideItemIf,
|
||||||
listContainerClassName,
|
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 ? (
|
return results.length === 0 ? (
|
||||||
Placeholder && <Placeholder searchQuery={searchQuery} />
|
Placeholder && <Placeholder searchQuery={searchQuery} />
|
||||||
) : (
|
) : (
|
||||||
@ -35,7 +72,13 @@ export default function ItemList({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClick = () => onClickItem?.(result);
|
const onClick = () => {
|
||||||
|
if (result.notImported) {
|
||||||
|
onOpenImportTokenModalClick(result);
|
||||||
|
} else {
|
||||||
|
onClickItem?.(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
const {
|
const {
|
||||||
iconUrl,
|
iconUrl,
|
||||||
identiconAddress,
|
identiconAddress,
|
||||||
@ -96,9 +139,42 @@ export default function ItemList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{result.notImported && (
|
||||||
|
<Button type="confirm" onClick={onClick} rounded>
|
||||||
|
{t('import')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -117,6 +193,7 @@ ItemList.propTypes = {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
onClickItem: PropTypes.func,
|
onClickItem: PropTypes.func,
|
||||||
|
onOpenImportTokenModalClick: PropTypes.func,
|
||||||
Placeholder: PropTypes.func,
|
Placeholder: PropTypes.func,
|
||||||
listTitle: PropTypes.string,
|
listTitle: PropTypes.string,
|
||||||
maxListItems: PropTypes.number,
|
maxListItems: PropTypes.number,
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
|
import log from 'loglevel';
|
||||||
import InputAdornment from '@material-ui/core/InputAdornment';
|
import InputAdornment from '@material-ui/core/InputAdornment';
|
||||||
import TextField from '../../../../components/ui/text-field';
|
import TextField from '../../../../components/ui/text-field';
|
||||||
import { usePrevious } from '../../../../hooks/usePrevious';
|
import { usePrevious } from '../../../../hooks/usePrevious';
|
||||||
|
import { isValidHexAddress } from '../../../../../shared/modules/hexstring-utils';
|
||||||
|
import { fetchToken } from '../../swaps.util';
|
||||||
|
import { getCurrentChainId } from '../../../../selectors/selectors';
|
||||||
|
|
||||||
const renderAdornment = () => (
|
const renderAdornment = () => (
|
||||||
<InputAdornment position="start" style={{ marginRight: '12px' }}>
|
<InputAdornment position="start" style={{ marginRight: '12px' }}>
|
||||||
@ -18,17 +23,53 @@ export default function ListItemSearch({
|
|||||||
fuseSearchKeys,
|
fuseSearchKeys,
|
||||||
searchPlaceholderText,
|
searchPlaceholderText,
|
||||||
defaultToAll,
|
defaultToAll,
|
||||||
|
shouldSearchForImports,
|
||||||
}) {
|
}) {
|
||||||
const fuseRef = useRef();
|
const fuseRef = useRef();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
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 fuseSearchResult = fuseRef.current.search(newSearchQuery);
|
||||||
|
const results =
|
||||||
|
defaultToAll && newSearchQuery === '' ? listToSearch : fuseSearchResult;
|
||||||
|
if (shouldSearchForImports && results.length === 0 && validHexAddress) {
|
||||||
|
await handleSearchTokenForImport(trimmedNewSearchQuery);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchQuery(newSearchQuery);
|
||||||
onSearch({
|
onSearch({
|
||||||
searchQuery: newSearchQuery,
|
searchQuery: newSearchQuery,
|
||||||
results:
|
results,
|
||||||
defaultToAll && newSearchQuery === '' ? listToSearch : fuseSearchResult,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -83,4 +124,5 @@ ListItemSearch.propTypes = {
|
|||||||
fuseSearchKeys: PropTypes.arrayOf(PropTypes.object).isRequired,
|
fuseSearchKeys: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
searchPlaceholderText: PropTypes.string,
|
searchPlaceholderText: PropTypes.string,
|
||||||
defaultToAll: PropTypes.bool,
|
defaultToAll: PropTypes.bool,
|
||||||
|
shouldSearchForImports: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
@ -12,11 +12,13 @@ export default function SearchableItemList({
|
|||||||
listTitle,
|
listTitle,
|
||||||
maxListItems,
|
maxListItems,
|
||||||
onClickItem,
|
onClickItem,
|
||||||
|
onOpenImportTokenModalClick,
|
||||||
Placeholder,
|
Placeholder,
|
||||||
searchPlaceholderText,
|
searchPlaceholderText,
|
||||||
hideRightLabels,
|
hideRightLabels,
|
||||||
hideItemIf,
|
hideItemIf,
|
||||||
listContainerClassName,
|
listContainerClassName,
|
||||||
|
shouldSearchForImports,
|
||||||
}) {
|
}) {
|
||||||
const itemListRef = useRef();
|
const itemListRef = useRef();
|
||||||
|
|
||||||
@ -38,11 +40,13 @@ export default function SearchableItemList({
|
|||||||
error={itemSelectorError}
|
error={itemSelectorError}
|
||||||
searchPlaceholderText={searchPlaceholderText}
|
searchPlaceholderText={searchPlaceholderText}
|
||||||
defaultToAll={defaultToAll}
|
defaultToAll={defaultToAll}
|
||||||
|
shouldSearchForImports={shouldSearchForImports}
|
||||||
/>
|
/>
|
||||||
<ItemList
|
<ItemList
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
results={results}
|
results={results}
|
||||||
onClickItem={onClickItem}
|
onClickItem={onClickItem}
|
||||||
|
onOpenImportTokenModalClick={onOpenImportTokenModalClick}
|
||||||
Placeholder={Placeholder}
|
Placeholder={Placeholder}
|
||||||
listTitle={listTitle}
|
listTitle={listTitle}
|
||||||
maxListItems={maxListItems}
|
maxListItems={maxListItems}
|
||||||
@ -59,6 +63,7 @@ SearchableItemList.propTypes = {
|
|||||||
itemSelectorError: PropTypes.string,
|
itemSelectorError: PropTypes.string,
|
||||||
itemsToSearch: PropTypes.array,
|
itemsToSearch: PropTypes.array,
|
||||||
onClickItem: PropTypes.func,
|
onClickItem: PropTypes.func,
|
||||||
|
onOpenImportTokenModalClick: PropTypes.func,
|
||||||
Placeholder: PropTypes.func,
|
Placeholder: PropTypes.func,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
searchPlaceholderText: PropTypes.string,
|
searchPlaceholderText: PropTypes.string,
|
||||||
@ -74,4 +79,5 @@ SearchableItemList.propTypes = {
|
|||||||
hideRightLabels: PropTypes.bool,
|
hideRightLabels: PropTypes.bool,
|
||||||
hideItemIf: PropTypes.func,
|
hideItemIf: PropTypes.func,
|
||||||
listContainerClassName: PropTypes.string,
|
listContainerClassName: PropTypes.string,
|
||||||
|
shouldSearchForImports: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import configureMockStore from 'redux-mock-store';
|
||||||
|
|
||||||
import { renderWithProvider } from '../../../../test/jest';
|
import {
|
||||||
|
renderWithProvider,
|
||||||
|
createSwapsMockStore,
|
||||||
|
} from '../../../../test/jest';
|
||||||
import SearchableItemList from '.';
|
import SearchableItemList from '.';
|
||||||
|
|
||||||
const createProps = (customProps = {}) => {
|
const createProps = (customProps = {}) => {
|
||||||
@ -37,8 +41,12 @@ const createProps = (customProps = {}) => {
|
|||||||
|
|
||||||
describe('SearchableItemList', () => {
|
describe('SearchableItemList', () => {
|
||||||
it('renders the component with initial props', () => {
|
it('renders the component with initial props', () => {
|
||||||
|
const store = configureMockStore()(createSwapsMockStore());
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
const { getByText } = renderWithProvider(<SearchableItemList {...props} />);
|
const { getByText } = renderWithProvider(
|
||||||
|
<SearchableItemList {...props} />,
|
||||||
|
store,
|
||||||
|
);
|
||||||
expect(getByText(props.listTitle)).toBeInTheDocument();
|
expect(getByText(props.listTitle)).toBeInTheDocument();
|
||||||
expect(getByText(props.itemsToSearch[0].primaryLabel)).toBeInTheDocument();
|
expect(getByText(props.itemsToSearch[0].primaryLabel)).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
|
@ -10,7 +10,6 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
cursor: pointer;
|
|
||||||
background: unset;
|
background: unset;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
@ -47,6 +47,8 @@ const getBaseApi = function (type, chainId = MAINNET_CHAIN_ID) {
|
|||||||
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/trades?`;
|
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/trades?`;
|
||||||
case 'tokens':
|
case 'tokens':
|
||||||
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/tokens`;
|
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/tokens`;
|
||||||
|
case 'token':
|
||||||
|
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/token`;
|
||||||
case 'topAssets':
|
case 'topAssets':
|
||||||
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/topAssets`;
|
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/topAssets`;
|
||||||
case 'featureFlag':
|
case 'featureFlag':
|
||||||
@ -290,10 +292,20 @@ export async function fetchTradesInfo(
|
|||||||
return newQuotes;
|
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) {
|
export async function fetchTokens(chainId) {
|
||||||
const tokenUrl = getBaseApi('tokens', chainId);
|
const tokensUrl = getBaseApi('tokens', chainId);
|
||||||
const tokens = await fetchWithCache(
|
const tokens = await fetchWithCache(
|
||||||
tokenUrl,
|
tokensUrl,
|
||||||
{ method: 'GET' },
|
{ method: 'GET' },
|
||||||
{ cacheRefreshTime: CACHE_REFRESH_FIVE_MINUTES },
|
{ cacheRefreshTime: CACHE_REFRESH_FIVE_MINUTES },
|
||||||
);
|
);
|
||||||
@ -301,7 +313,7 @@ export async function fetchTokens(chainId) {
|
|||||||
SWAPS_CHAINID_DEFAULT_TOKEN_MAP[chainId],
|
SWAPS_CHAINID_DEFAULT_TOKEN_MAP[chainId],
|
||||||
...tokens.filter((token) => {
|
...tokens.filter((token) => {
|
||||||
return (
|
return (
|
||||||
validateData(TOKEN_VALIDATORS, token, tokenUrl) &&
|
validateData(TOKEN_VALIDATORS, token, tokensUrl) &&
|
||||||
!(
|
!(
|
||||||
isSwapsDefaultTokenSymbol(token.symbol, chainId) ||
|
isSwapsDefaultTokenSymbol(token.symbol, chainId) ||
|
||||||
isSwapsDefaultTokenAddress(token.address, chainId)
|
isSwapsDefaultTokenAddress(token.address, chainId)
|
||||||
|
Loading…
Reference in New Issue
Block a user