1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-22 09:23:21 +01:00

Fix #19829: UX: Multichain: Move "Import Tokens" to Modal (#19553)

* Move Import Tokens to Modal

* Better dimensions for long token name

* Add padding above tabs
This commit is contained in:
David Walsh 2023-08-14 11:08:59 -05:00 committed by GitHub
parent d6eecf8584
commit ee4bf2d264
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
101 changed files with 1339 additions and 1860 deletions

View File

@ -609,9 +609,6 @@
"searchResults": {
"message": "ውጤቶችን ፈልግ"
},
"searchTokens": {
"message": "ተለዋጭ ስሞችን ፈልግ"
},
"securityAndPrivacy": {
"message": "ደህንነት እና ግላዊነት"
},

View File

@ -621,9 +621,6 @@
"searchResults": {
"message": "نتائج البحث"
},
"searchTokens": {
"message": "البحث عن العملات الرمزية"
},
"securityAndPrivacy": {
"message": "الأمن والخصوصية"
},

View File

@ -620,9 +620,6 @@
"searchResults": {
"message": "Резултати от търсенето"
},
"searchTokens": {
"message": "Търсене на маркери"
},
"securityAndPrivacy": {
"message": "Сигурност и поверителност"
},

View File

@ -618,9 +618,6 @@
"searchResults": {
"message": "অনুসন্ধানের ফলাফলগুলি"
},
"searchTokens": {
"message": "টোকেনগুলি অনুসন্ধান করুন"
},
"securityAndPrivacy": {
"message": "নিরাপত্তা এবং গোপনীয়তা"
},

View File

@ -605,9 +605,6 @@
"searchResults": {
"message": "Resultats de Cerca"
},
"searchTokens": {
"message": "Tokens per cercar"
},
"securityAndPrivacy": {
"message": "Seguretat i privacitat"
},

View File

@ -289,9 +289,6 @@
"search": {
"message": "Hledat"
},
"searchTokens": {
"message": "Hledat tokeny"
},
"seedPhraseReq": {
"message": "klíčové fráze mají 12 slov"
},

View File

@ -605,9 +605,6 @@
"searchResults": {
"message": "Søg Resultater"
},
"searchTokens": {
"message": "Søg efter tokens"
},
"securityAndPrivacy": {
"message": "Sikkerhed & Privatliv"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "Benutzerdefiniertes Netzwerk hinzufügen"
},
"addCustomToken": {
"message": "Kunden-Token hinzufügen"
},
"addEthereumChainConfirmationDescription": {
"message": "Dadurch kann dieses Netzwerk innerhalb MetaMask verwendet werden."
},
@ -2980,9 +2977,6 @@
"searchResults": {
"message": "Suchergebnisse"
},
"searchTokens": {
"message": "Token suchen"
},
"secretRecoveryPhrase": {
"message": "Geheime Wiederherstellungsphrase"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "Προσθήκη προσαρμοσμένου δικτύου"
},
"addCustomToken": {
"message": "Προσθήκη Προσαρμοσμένου Token"
},
"addEthereumChainConfirmationDescription": {
"message": "Αυτό θα επιτρέψει σε αυτό το δίκτυο να χρησιμοποιηθεί στο MetaMask."
},
@ -2977,9 +2974,6 @@
"searchAccounts": {
"message": "Αναζήτηση Λογαριασμών"
},
"searchTokens": {
"message": "Αναζήτηση Tokens"
},
"secretRecoveryPhrase": {
"message": "Μυστική Φράση Ανάκτησης"
},

View File

@ -192,9 +192,6 @@
"addCustomNetwork": {
"message": "Add custom network"
},
"addCustomToken": {
"message": "Add custom token"
},
"addEthereumChainConfirmationDescription": {
"message": "This will allow this network to be used within MetaMask."
},
@ -3653,9 +3650,6 @@
"searchResults": {
"message": "Search results"
},
"searchTokens": {
"message": "Search tokens"
},
"secretRecoveryPhrase": {
"message": "Secret Recovery Phrase"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "Agregar red personalizada"
},
"addCustomToken": {
"message": "Añadir token personalizado"
},
"addEthereumChainConfirmationDescription": {
"message": "Esto permitirá que la red se utilice en MetaMask."
},
@ -2980,9 +2977,6 @@
"searchResults": {
"message": "Resultados de la búsqueda"
},
"searchTokens": {
"message": "Buscar tokens"
},
"secretRecoveryPhrase": {
"message": "Frase secreta de recuperación"
},

View File

@ -100,9 +100,6 @@
"addContact": {
"message": "Agregar contacto"
},
"addCustomToken": {
"message": "Añadir token personalizado"
},
"addEthereumChainConfirmationDescription": {
"message": "Esto permitirá que la red se utilice en MetaMask."
},
@ -1882,9 +1879,6 @@
"searchResults": {
"message": "Resultados de la búsqueda"
},
"searchTokens": {
"message": "Buscar tokens"
},
"secretRecoveryPhrase": {
"message": "Frase secreta de recuperación"
},

View File

@ -614,9 +614,6 @@
"searchResults": {
"message": "Otsingutulemused"
},
"searchTokens": {
"message": "Lubade otsimine"
},
"securityAndPrivacy": {
"message": "Turvalisus ja privaatsus"
},

View File

@ -624,9 +624,6 @@
"searchResults": {
"message": "نتایج جستجو"
},
"searchTokens": {
"message": "رمزیاب های جستجو"
},
"securityAndPrivacy": {
"message": "امنیت و حریم خصوصی"
},

View File

@ -621,9 +621,6 @@
"searchResults": {
"message": "Hakutulokset"
},
"searchTokens": {
"message": "Hae tietueita"
},
"securityAndPrivacy": {
"message": "Turva & yksityisyys"
},

View File

@ -548,9 +548,6 @@
"searchResults": {
"message": "Mga Resulta ng Paghahanap"
},
"searchTokens": {
"message": "Maghanap ng Mga Token"
},
"securityAndPrivacy": {
"message": "Seguridad at Privacy"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "Ajouter un réseau personnalisé"
},
"addCustomToken": {
"message": "Ajouter un jeton personnalisé"
},
"addEthereumChainConfirmationDescription": {
"message": "Cela permettra dutiliser ce réseau dans MetaMask."
},
@ -2980,9 +2977,6 @@
"searchResults": {
"message": "Résultats de la recherche"
},
"searchTokens": {
"message": "Rechercher des jetons"
},
"secretRecoveryPhrase": {
"message": "Phrase secrète de récupération"
},

View File

@ -621,9 +621,6 @@
"searchResults": {
"message": "תוצאות חיפוש"
},
"searchTokens": {
"message": "חיפוש טוקנים"
},
"securityAndPrivacy": {
"message": "אבטחה ופרטיות"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "कस्टम नेटवर्क जोड़ें"
},
"addCustomToken": {
"message": "कस्टम टोकन जोड़ें"
},
"addEthereumChainConfirmationDescription": {
"message": "इससे इस नेटवर्क को MetaMask के अंदर उपयोग करने की अनुमति मिलेगी।"
},
@ -2980,9 +2977,6 @@
"searchResults": {
"message": "खोज परिणाम"
},
"searchTokens": {
"message": "टोकन खोजें"
},
"secretRecoveryPhrase": {
"message": "सीक्रेट रिकवरी फ्रेज"
},

View File

@ -617,9 +617,6 @@
"searchResults": {
"message": "Rezultati pretraživanja"
},
"searchTokens": {
"message": "Pretraži tokene"
},
"securityAndPrivacy": {
"message": "Sigurnost i privatnost"
},

View File

@ -452,9 +452,6 @@
"searchResults": {
"message": "Rezilta rechèch"
},
"searchTokens": {
"message": "Rechèch Tokens"
},
"seedPhraseReq": {
"message": "Seed fraz yo se 12 long mo"
},

View File

@ -617,9 +617,6 @@
"searchResults": {
"message": "Keresési eredmények"
},
"searchTokens": {
"message": "Keresés a tokenek között"
},
"securityAndPrivacy": {
"message": "Biztonság és adatvédelem"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "Tambahkan jaringan khusus"
},
"addCustomToken": {
"message": "Tambahkan token kustom"
},
"addEthereumChainConfirmationDescription": {
"message": "Tindakan ini akan membantu jaringan ini agar dapat digunakan dengan MetaMask."
},
@ -2980,9 +2977,6 @@
"searchResults": {
"message": "Cari hasil"
},
"searchTokens": {
"message": "Cari token"
},
"secretRecoveryPhrase": {
"message": "Frasa Pemulihan Rahasia"
},

View File

@ -155,9 +155,6 @@
"addContact": {
"message": "Aggiungi contatto"
},
"addCustomToken": {
"message": "Aggiungi token personalizzato"
},
"addEthereumChainConfirmationDescription": {
"message": "Ciò consentirà a questa rete di essere utilizzata all'interno di MetaMask."
},
@ -1380,9 +1377,6 @@
"searchResults": {
"message": "Risultati Ricerca"
},
"searchTokens": {
"message": "Cerca Tokens"
},
"securityAndPrivacy": {
"message": "Sicurezza & Privacy"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "カスタムネットワークを追加"
},
"addCustomToken": {
"message": "カスタムトークンを追加"
},
"addEthereumChainConfirmationDescription": {
"message": "これにより、このネットワークはMetaMask内で使用できるようになります。"
},
@ -2980,9 +2977,6 @@
"searchResults": {
"message": "検索結果"
},
"searchTokens": {
"message": "トークンを検索"
},
"secretRecoveryPhrase": {
"message": "シークレットリカバリーフレーズ"
},

View File

@ -624,9 +624,6 @@
"searchResults": {
"message": "ಹುಡುಕಾಟ ಫಲಿತಾಂಶಗಳು"
},
"searchTokens": {
"message": "ಟೋಕನ್‌ಗಳನ್ನು ಹುಡುಕಿ"
},
"securityAndPrivacy": {
"message": "ಭದ್ರತೆ ಮತ್ತು ಗೌಪ್ಯತೆ"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "커스텀 네트워크 추가"
},
"addCustomToken": {
"message": "커스텀 토큰 추가"
},
"addEthereumChainConfirmationDescription": {
"message": "이렇게 하면 MetaMask 내에서 이 네트워크를 사용할 수 있습니다."
},
@ -2980,9 +2977,6 @@
"searchResults": {
"message": "검색 결과"
},
"searchTokens": {
"message": "토큰 검색"
},
"secretRecoveryPhrase": {
"message": "비밀 복구 구문"
},

View File

@ -624,9 +624,6 @@
"searchResults": {
"message": "Paieškos rezultatai"
},
"searchTokens": {
"message": "Ieškoti žetonų"
},
"securityAndPrivacy": {
"message": "Sauga ir privatumas"
},

View File

@ -620,9 +620,6 @@
"searchResults": {
"message": "Meklēšanas rezultāti"
},
"searchTokens": {
"message": "Meklēt marķierus"
},
"securityAndPrivacy": {
"message": "Drošība un konfidencialitāte"
},

View File

@ -604,9 +604,6 @@
"searchResults": {
"message": "Hasil Carian"
},
"searchTokens": {
"message": "Cari Token"
},
"securityAndPrivacy": {
"message": "Keselamatan & Privasi"
},

View File

@ -608,9 +608,6 @@
"searchResults": {
"message": "Søkeresultater"
},
"searchTokens": {
"message": "Søk i sjetonger"
},
"securityAndPrivacy": {
"message": "Sikkerhet og personvern"
},

View File

@ -1211,9 +1211,6 @@
"searchResults": {
"message": "Mga Resulta ng Paghahanap"
},
"searchTokens": {
"message": "Maghanap ng Mga Token"
},
"securityAndPrivacy": {
"message": "Seguridad at Privacy"
},

View File

@ -618,9 +618,6 @@
"searchResults": {
"message": "Wyniki wyszukiwania"
},
"searchTokens": {
"message": "Szukaj tokenów"
},
"securityAndPrivacy": {
"message": "Bezpieczeństwo i prywatność"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "Adicionar rede personalizada"
},
"addCustomToken": {
"message": "Adicionar token personalizado"
},
"addEthereumChainConfirmationDescription": {
"message": "Isso permitirá que essa rede seja usada dentro da MetaMask."
},
@ -2980,9 +2977,6 @@
"searchResults": {
"message": "Resultados da busca"
},
"searchTokens": {
"message": "Buscar tokens"
},
"secretRecoveryPhrase": {
"message": "Frase Secreta de Recuperação"
},

View File

@ -100,9 +100,6 @@
"addContact": {
"message": "Adicionar contato"
},
"addCustomToken": {
"message": "Adicionar token personalizado"
},
"addEthereumChainConfirmationDescription": {
"message": "Isso permitirá que essa rede seja usada dentro da MetaMask."
},
@ -1882,9 +1879,6 @@
"searchResults": {
"message": "Resultados da busca"
},
"searchTokens": {
"message": "Buscar tokens"
},
"secretRecoveryPhrase": {
"message": "Frase de Recuperação Secreta"
},

View File

@ -611,9 +611,6 @@
"searchResults": {
"message": "Rezultate căutare"
},
"searchTokens": {
"message": "Căutați token-uri"
},
"securityAndPrivacy": {
"message": "Securitate și confidențialitate"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "Добавить пользовательскую сеть"
},
"addCustomToken": {
"message": "Добавить пользовательский токен"
},
"addEthereumChainConfirmationDescription": {
"message": "Это позволит использовать эту сеть в MetaMask."
},
@ -2980,9 +2977,6 @@
"searchResults": {
"message": "Результаты поиска"
},
"searchTokens": {
"message": "Поиск токенов"
},
"secretRecoveryPhrase": {
"message": "Секретная фраза для восстановления"
},

View File

@ -596,9 +596,6 @@
"searchResults": {
"message": "Výsledky vyhľadávania"
},
"searchTokens": {
"message": "Hledat tokeny"
},
"securityAndPrivacy": {
"message": "Bezpečnosť a súkromie"
},

View File

@ -612,9 +612,6 @@
"searchResults": {
"message": "Rezultati iskanja"
},
"searchTokens": {
"message": "Iskanje žetonov"
},
"securityAndPrivacy": {
"message": "Varnost in zasebnost"
},

View File

@ -615,9 +615,6 @@
"searchResults": {
"message": "Rezultati pretrage"
},
"searchTokens": {
"message": "Pretražite tokene"
},
"securityAndPrivacy": {
"message": "Bezbednost i privatnost"
},

View File

@ -608,9 +608,6 @@
"searchResults": {
"message": "Sökresultat"
},
"searchTokens": {
"message": "Sök tokens"
},
"securityAndPrivacy": {
"message": "Säkerhet och integritet"
},

View File

@ -602,9 +602,6 @@
"searchResults": {
"message": "Matokeo ya Utafutaji"
},
"searchTokens": {
"message": "Tafuta Vianzio"
},
"securityAndPrivacy": {
"message": "Ulinzi na Faragha"
},

View File

@ -359,9 +359,6 @@
"search": {
"message": "தேடல்"
},
"searchTokens": {
"message": "தேடல் டோக்கன்ஸ்"
},
"seedPhraseReq": {
"message": "விதை வாக்கியங்கள் 12 வார்த்தைகள் நீண்டவை"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "Magdagdag ng custom na network"
},
"addCustomToken": {
"message": "Magdagdag ng Custom na Token"
},
"addEthereumChainConfirmationDescription": {
"message": "Magpapahintulot ito sa network na ito na gamitin sa loob ng MetaMask."
},
@ -2980,9 +2977,6 @@
"searchResults": {
"message": "Mga Resulta ng Paghahanap"
},
"searchTokens": {
"message": "Maghanap ng Mga Token"
},
"secretRecoveryPhrase": {
"message": "Lihim na recovery phrase"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "Özel ağ ekle"
},
"addCustomToken": {
"message": "Özel token ekle"
},
"addEthereumChainConfirmationDescription": {
"message": "Bu, bu ağın MetaMas dahilinde kullanılmasına olanak tanıyacaktır."
},
@ -2980,9 +2977,6 @@
"searchResults": {
"message": "Arama sonuçları"
},
"searchTokens": {
"message": "Token ara"
},
"secretRecoveryPhrase": {
"message": "Gizli Kurtarma İfadesi"
},

View File

@ -624,9 +624,6 @@
"searchResults": {
"message": "Результати пошуку"
},
"searchTokens": {
"message": "Шукати токени"
},
"securityAndPrivacy": {
"message": "Безпека й конфіденційність"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "Thêm mạng tùy chỉnh"
},
"addCustomToken": {
"message": "Thêm token tùy chỉnh"
},
"addEthereumChainConfirmationDescription": {
"message": "Thao tác này sẽ cho phép sử dụng mạng này trong MetaMask."
},
@ -2980,9 +2977,6 @@
"searchResults": {
"message": "Kết quả tìm kiếm"
},
"searchTokens": {
"message": "Tìm kiếm token"
},
"secretRecoveryPhrase": {
"message": "Cụm Mật Khẩu Khôi Phục Bí Mật"
},

View File

@ -186,9 +186,6 @@
"addCustomNetwork": {
"message": "添加自定义网络"
},
"addCustomToken": {
"message": "添加自定义代币"
},
"addEthereumChainConfirmationDescription": {
"message": "这将允许在 MetaMask 中使用此网络。"
},
@ -2980,9 +2977,6 @@
"searchResults": {
"message": "搜索结果"
},
"searchTokens": {
"message": "搜索代币"
},
"secretRecoveryPhrase": {
"message": "助记词"
},

View File

@ -42,9 +42,6 @@
"addContact": {
"message": "新增合約"
},
"addCustomToken": {
"message": "Add Custom Token"
},
"addEthereumChainConfirmationDescription": {
"message": "這會允許在 MetaMask 內使用這個網路。"
},
@ -1133,9 +1130,6 @@
"searchResults": {
"message": "搜尋結果"
},
"searchTokens": {
"message": "搜尋代幣"
},
"secureWallet": {
"message": "Secure Wallet"
},

View File

@ -257,13 +257,18 @@ describe('MetaMask', function () {
});
await driver.delay(regularDelayMs);
await driver.fill('#custom-address', tokenAddress);
await driver.fill(
'[data-testid="import-tokens-modal-custom-address"]',
tokenAddress,
);
await driver.delay(regularDelayMs);
await driver.clickElement({ text: 'Add custom token', tag: 'button' });
await driver.clickElement({ text: 'Next', tag: 'button' });
await driver.delay(regularDelayMs);
await driver.clickElement({ text: 'Import tokens', tag: 'button' });
await driver.clickElement(
'[data-testid="import-tokens-modal-import-button"]',
);
await driver.delay(regularDelayMs);
});

View File

@ -108,13 +108,15 @@ describe('Add existing token using search', function () {
await driver.press('#password', driver.Key.ENTER);
await driver.clickElement({ text: 'Import tokens', tag: 'button' });
await driver.fill('#search-tokens', 'BAT');
await driver.fill('input[placeholder="Search"]', 'BAT');
await driver.clickElement({
text: 'BAT',
tag: 'span',
});
await driver.clickElement({ text: 'Next', tag: 'button' });
await driver.clickElement({ text: 'Import tokens', tag: 'button' });
await driver.clickElement(
'[data-testid="import-tokens-modal-import-button"]',
);
await driver.waitForSelector({
css: '.token-overview__primary-balance',

View File

@ -51,20 +51,24 @@ describe('Create token, approve token and approve token without gas', function (
text: 'Custom token',
tag: 'button',
});
await driver.fill('#custom-address', contractAddress);
await driver.waitForSelector('#custom-decimals');
await driver.fill(
'[data-testid="import-tokens-modal-custom-address"]',
contractAddress,
);
await driver.waitForSelector(
'[data-testid="import-tokens-modal-custom-decimals"]',
);
await driver.delay(2000);
await driver.clickElement({
text: 'Add custom token',
text: 'Next',
tag: 'button',
});
await driver.delay(2000);
await driver.clickElement({
text: 'Import tokens',
tag: 'button',
});
await driver.clickElement(
'[data-testid="import-tokens-modal-import-button"]',
);
// renders balance for newly created token
await driver.clickElement('.app-header__logo-container');

View File

@ -37,14 +37,20 @@ describe('Import flow', function () {
await driver.delay(regularDelayMs);
await driver.clickElement('[data-testid="import-token-button"]');
await driver.fill('input[placeholder="Search tokens"]', 'cha');
await driver.fill('input[placeholder="Search"]', 'cha');
await driver.clickElement('.token-list__token');
await driver.clickElement('.token-list__token:nth-of-type(2)');
await driver.clickElement('.token-list__token:nth-of-type(3)');
await driver.clickElement({ css: 'button', text: 'Next' });
await driver.clickElement({ css: 'button', text: 'Import' });
await driver.clickElement({
css: '.import-tokens-modal button',
text: 'Next',
});
await driver.clickElement({
css: '.import-tokens-modal button',
text: 'Import',
});
await driver.clickElement('.asset-breadcrumb');

View File

@ -30,11 +30,19 @@ describe('Token Details', function () {
const tokenAddress = '0x2EFA2Cb29C2341d8E5Ba7D3262C9e9d6f1Bf3711';
const tokenSymbol = 'AAVE';
await driver.fill('#custom-address', tokenAddress);
await driver.waitForSelector('#custom-symbol-helper-text');
await driver.fill('#custom-symbol', tokenSymbol);
await driver.clickElement({ text: 'Add custom token', tag: 'button' });
await driver.clickElement({ text: 'Import tokens', tag: 'button' });
await driver.fill(
'[data-testid="import-tokens-modal-custom-address"]',
tokenAddress,
);
await driver.waitForSelector('p.mm-box--color-error-default');
await driver.fill(
'[data-testid="import-tokens-modal-custom-symbol"]',
tokenSymbol,
);
await driver.clickElement({ text: 'Next', tag: 'button' });
await driver.clickElement(
'[data-testid="import-tokens-modal-import-button"]',
);
await driver.clickElement('[aria-label="Asset options"]');
await driver.clickElement({ text: 'Token details', tag: 'div' });

View File

@ -11,6 +11,7 @@
@import 'confirm-data/index';
@import 'confirmation-warning-modal/index';
@import 'custom-nonce/index';
@import 'import-token/index';
@import 'nfts-items/index';
@import 'nfts-tab/index';
@import 'nft-details/index';

View File

@ -0,0 +1 @@
@import 'token-list/index';

View File

@ -1,5 +1,3 @@
@import 'token-list-placeholder/index';
.token-list {
&__title {
@include H7;

View File

@ -0,0 +1,35 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import ZENDESK_URLS from '../../../../../helpers/constants/zendesk-url';
import { ButtonLink, Text, Box } from '../../../../component-library';
import {
Display,
FlexDirection,
TextAlign,
TextColor,
AlignItems,
} from '../../../../../helpers/constants/design-system';
export default class TokenListPlaceholder extends Component {
static contextTypes = {
t: PropTypes.func,
};
render() {
return (
<Box
display={Display.Flex}
alignItems={AlignItems.center}
flexDirection={FlexDirection.Column}
textAlign={TextAlign.Center}
>
<Text color={TextColor.textAlternative}>
{this.context.t('addAcquiredTokens')}
</Text>
<ButtonLink href={ZENDESK_URLS.ADD_CUSTOM_TOKENS} externalLink>
{this.context.t('learnMoreUpperCase')}
</ButtonLink>
</Box>
);
}
}

View File

@ -2,7 +2,7 @@ import React from 'react';
import TokenListPlaceholder from './token-list-placeholder.component';
export default {
title: 'Pages/ImportToken/TokenList/TokenListPlaceholder',
title: 'Components/App/TokenList/TokenListPlaceholder',
};
export const DefaultStory = () => {

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { checkExistingAddresses } from '../../../helpers/utils/util';
import { checkExistingAddresses } from '../../../../helpers/utils/util';
import TokenListPlaceholder from './token-list-placeholder';
export default class TokenList extends Component {

View File

@ -1,10 +1,9 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Fuse from 'fuse.js';
import InputAdornment from '@material-ui/core/InputAdornment';
import TextField from '../../../components/ui/text-field';
import { isEqualCaseInsensitive } from '../../../../shared/modules/string-utils';
import SearchIcon from '../../../components/ui/icon/search-icon';
import { isEqualCaseInsensitive } from '../../../../../shared/modules/string-utils';
import { TextFieldSearch } from '../../../component-library';
import { BlockSize } from '../../../../helpers/constants/design-system';
export default class TokenSearch extends Component {
static contextTypes = {
@ -57,30 +56,19 @@ export default class TokenSearch extends Component {
this.props.onSearch({ searchQuery, results });
}
renderAdornment() {
return (
<InputAdornment position="start" style={{ marginRight: '12px' }}>
<SearchIcon color="var(--color-icon-muted)" />
</InputAdornment>
);
}
render() {
const { error } = this.props;
const { searchQuery } = this.state;
return (
<TextField
id="search-tokens"
placeholder={this.context.t('searchTokens')}
type="text"
<TextFieldSearch
placeholder={this.context.t('search')}
value={searchQuery}
onChange={(e) => this.handleSearch(e.target.value)}
error={error}
fullWidth
autoFocus
autoComplete="off"
startAdornment={this.renderAdornment()}
autoComplete={false}
width={BlockSize.Full}
/>
);
}

View File

@ -1,9 +1,9 @@
import React from 'react';
import testData from '../../../../.storybook/test-data';
import testData from '../../../../../.storybook/test-data';
import TokenSearch from './token-search.component';
export default {
title: 'Pages/ImportToken/TokenSearch',
title: 'Components/App/ImportToken/TokenSearch',
argTypes: {
error: {

View File

@ -1,6 +1,5 @@
import React, { useContext } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { ButtonLink, IconName, Box } from '../../component-library';
@ -10,8 +9,7 @@ import {
Size,
} from '../../../helpers/constants/design-system';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { IMPORT_TOKEN_ROUTE } from '../../../helpers/constants/routes';
import { detectNewTokens } from '../../../store/actions';
import { detectNewTokens, showImportTokensModal } from '../../../store/actions';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import {
MetaMetricsEventCategory,
@ -25,7 +23,6 @@ import {
export const ImportTokenLink = ({ className, ...props }) => {
const trackEvent = useContext(MetaMetricsContext);
const t = useI18nContext();
const history = useHistory();
const dispatch = useDispatch();
const isTokenDetectionSupported = useSelector(getIsTokenDetectionSupported);
@ -48,7 +45,7 @@ export const ImportTokenLink = ({ className, ...props }) => {
data-testid="import-token-button"
startIconName={IconName.Add}
onClick={() => {
history.push(IMPORT_TOKEN_ROUTE);
dispatch(showImportTokensModal());
trackEvent({
event: MetaMetricsEventName.TokenImportButtonClicked,
category: MetaMetricsEventCategory.Navigation,

View File

@ -19,7 +19,12 @@ jest.mock('react-router-dom', () => {
});
jest.mock('../../../store/actions.ts', () => ({
detectNewTokens: jest.fn().mockReturnValue({ type: '' }),
detectNewTokens: jest
.fn()
.mockImplementation(() => ({ type: 'DETECT_TOKENS' })),
showImportTokensModal: jest
.fn()
.mockImplementation(() => ({ type: 'UI_IMPORT_TOKENS_POPOVER_OPEN' })),
}));
describe('Import Token Link', () => {
@ -90,6 +95,6 @@ describe('Import Token Link', () => {
const importToken = screen.getByTestId('import-token-button');
fireEvent.click(importToken);
expect(mockPushHistory).toHaveBeenCalledWith('/import-token');
expect(screen.getByText('Import tokens')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,108 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import {
Box,
ButtonPrimary,
ButtonSecondary,
Text,
} from '../../component-library';
import {
AlignItems,
Display,
Size,
TextAlign,
TextColor,
TextVariant,
} from '../../../helpers/constants/design-system';
import TokenBalance from '../../ui/token-balance/token-balance';
import Identicon from '../../ui/identicon';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { getPendingTokens } from '../../../ducks/metamask/metamask';
export const ImportTokensModalConfirm = ({ onBackClick, onImportClick }) => {
const t = useI18nContext();
const pendingTokens = useSelector(getPendingTokens);
return (
<Box paddingTop={4} paddingBottom={4}>
<Text textAlign={TextAlign.Center}>{t('likeToImportTokens')}</Text>
<Box marginTop={4} marginBottom={4}>
<Box display={Display.Flex}>
<Text
variant={TextVariant.bodySm}
className="import-tokens-modal__token-name"
>
{t('token')}
</Text>
<Text
variant={TextVariant.bodySm}
className="import-tokens-modal__token-balance"
>
{t('balance')}
</Text>
</Box>
<Box
display={Display.Flex}
className="import-tokens-modal__confirm-token-list"
>
{Object.entries(pendingTokens).map(([address, token]) => {
const { name, symbol } = token;
return (
<Box
key={address}
marginBottom={4}
display={Display.Flex}
className="import-tokens-modal__confirm-token-list-item"
>
<Box
display={Display.Flex}
alignItems={AlignItems.center}
className="import-tokens-modal__confirm-token-list-item-wrapper"
>
<Identicon diameter={36} address={address} />
<Box
marginInlineStart={4}
className="import-tokens-modal__confirm-token-list-item-wrapper__text"
>
<Text ellipsis>{name}</Text>
<Text
variant={TextVariant.bodySm}
color={TextColor.textAlternative}
>
{symbol}
</Text>
</Box>
</Box>
<Box
className="import-tokens-modal__token-balance"
alignItems={AlignItems.flexStart}
>
<TokenBalance token={token} />
</Box>
</Box>
);
})}
</Box>
<Box display={Display.Flex} gap={2} marginTop={4}>
<ButtonSecondary size={Size.LG} onClick={onBackClick} block>
{t('back')}
</ButtonSecondary>
<ButtonPrimary
size={Size.LG}
onClick={onImportClick}
block
data-testid="import-tokens-modal-import-button"
>
{t('import')}
</ButtonPrimary>
</Box>
</Box>
</Box>
);
};
ImportTokensModalConfirm.propTypes = {
onBackClick: PropTypes.func.isRequired,
onImportClick: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,100 @@
import React from 'react';
import { Provider } from 'react-redux';
import configureStore from '../../../store/store';
import testData from '../../../../.storybook/test-data';
import { CHAIN_IDS } from '../../../../shared/constants/network';
import { ImportTokensModalConfirm } from './import-tokens-modal-confirm';
const createStore = (
chainId = CHAIN_IDS.MAINNET,
useTokenDetection = true,
tokenRepetition = 1,
) => {
return configureStore({
...testData,
metamask: {
...testData.metamask,
useTokenDetection,
providerConfig: { chainId },
pendingTokens: {
'0x0000000de40dfa9b17854cbc7869d80f9f98d823': {
address: '0x0000000de40dfa9b17854cbc7869d80f9f98d823',
aggregators: ['CoinGecko', 'Sonarwatch', 'Coinmarketcap'],
decimals: 18,
fees: {},
iconUrl:
'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x0000000de40dfa9b17854cbc7869d80f9f98d823.png',
name: 'delta theta'.repeat(tokenRepetition),
occurrences: 3,
symbol: 'DLTA',
type: 'erc20',
unlisted: false,
},
'0x00d8318e44780edeefcf3020a5448f636788883c': {
address: '0x00d8318e44780edeefcf3020a5448f636788883c',
aggregators: ['CoinGecko', 'Sonarwatch', 'Coinmarketcap'],
decimals: 18,
fees: {},
iconUrl:
'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x00d8318e44780edeefcf3020a5448f636788883c.png',
name: 'dAppstore'.repeat(tokenRepetition),
occurrences: 3,
symbol: 'DAPPX',
type: 'erc20',
unlisted: false,
},
'0x00e679ba63b509182c349f5614f0a07cdd0ce0c5': {
address: '0x00e679ba63b509182c349f5614f0a07cdd0ce0c5',
aggregators: ['CoinGecko', 'Sonarwatch', 'Coinmarketcap'],
decimals: 18,
iconUrl:
'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x00e679ba63b509182c349f5614f0a07cdd0ce0c5.png',
name: 'Damex Token'.repeat(tokenRepetition),
occurrences: 3,
symbol: 'DAMEX',
type: 'erc20',
unlisted: false,
},
},
},
});
};
export default {
title: 'Components/Multichain/ImportTokensModal/ImportTokensModalConfirm',
component: ImportTokensModalConfirm,
argTypes: {
onBackClick: {
action: 'onClose',
},
onImportClick: {
action: 'onClose',
},
},
};
export const DefaultStory = (args) => <ImportTokensModalConfirm {...args} />;
DefaultStory.decorators = [
(Story) => (
<Provider store={createStore()}>
<Story />
</Provider>
),
];
DefaultStory.storyName = 'Default';
export const LongValueStory = (args) => (
<div style={{ width: '300px' }}>
<ImportTokensModalConfirm {...args} />
</div>
);
LongValueStory.decorators = [
(Story) => (
<Provider store={createStore(CHAIN_IDS.MAINNET, true, 5)}>
<Story />
</Provider>
),
];
LongValueStory.storyName = 'LongValueStory';

View File

@ -0,0 +1,649 @@
import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import { getTokenTrackerLink } from '@metamask/etherscan-link/dist/token-tracker-link';
import { Tab, Tabs } from '../../ui/tabs';
import { useI18nContext } from '../../../hooks/useI18nContext';
import {
getCurrentChainId,
getIsDynamicTokenListAvailable,
getIsMainnet,
getIsTokenDetectionInactiveOnMainnet,
getIsTokenDetectionSupported,
getIstokenDetectionInactiveOnNonMainnetSupportedNetwork,
getMetaMaskIdentities,
getRpcPrefsForCurrentProvider,
getSelectedAddress,
getTokenDetectionSupportNetworkByChainId,
getTokenList,
} from '../../../selectors';
import {
addImportedTokens,
clearPendingTokens,
getTokenStandardAndDetails,
setPendingTokens,
showImportNftsModal,
} from '../../../store/actions';
import {
BannerAlert,
ButtonLink,
ButtonPrimary,
Text,
FormTextField,
Box,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
} from '../../component-library';
import TokenSearch from '../../app/import-token/token-search';
import TokenList from '../../app/import-token/token-list';
import {
FontWeight,
Severity,
Size,
TextAlign,
TextColor,
} from '../../../helpers/constants/design-system';
import { ASSET_ROUTE, SECURITY_ROUTE } from '../../../helpers/constants/routes';
import ZENDESK_URLS from '../../../helpers/constants/zendesk-url';
import { isValidHexAddress } from '../../../../shared/modules/hexstring-utils';
import { addHexPrefix } from '../../../../app/scripts/lib/util';
import { STATIC_MAINNET_TOKEN_LIST } from '../../../../shared/constants/tokens';
import {
AssetType,
TokenStandard,
} from '../../../../shared/constants/transaction';
import {
checkExistingAddresses,
getURLHostName,
} from '../../../helpers/utils/util';
import { tokenInfoGetter } from '../../../helpers/utils/token-util';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import { getPendingTokens } from '../../../ducks/metamask/metamask';
import {
MetaMetricsEventCategory,
MetaMetricsEventName,
MetaMetricsTokenEventSource,
} from '../../../../shared/constants/metametrics';
import { ImportTokensModalConfirm } from './import-tokens-modal-confirm';
export const ImportTokensModal = ({ onClose }) => {
const t = useI18nContext();
const history = useHistory();
const dispatch = useDispatch();
const [mode, setMode] = useState('');
const [tokenSelectorError, setTokenSelectorError] = useState(null);
const [selectedTokens, setSelectedTokens] = useState({});
const [searchResults, setSearchResults] = useState([]);
// Determine if we should show the search tab
const isTokenDetectionSupported = useSelector(getIsTokenDetectionSupported);
const isTokenDetectionInactiveOnMainnet = useSelector(
getIsTokenDetectionInactiveOnMainnet,
);
const showSearchTab =
isTokenDetectionSupported ||
isTokenDetectionInactiveOnMainnet ||
Boolean(process.env.IN_TEST);
const tokenList = useSelector(getTokenList);
const useTokenDetection = useSelector(
({ metamask }) => metamask.useTokenDetection,
);
const networkName = useSelector(getTokenDetectionSupportNetworkByChainId);
// Custom token stuff
const tokenDetectionInactiveOnNonMainnetSupportedNetwork = useSelector(
getIstokenDetectionInactiveOnNonMainnetSupportedNetwork,
);
const isDynamicTokenListAvailable = useSelector(
getIsDynamicTokenListAvailable,
);
const selectedAddress = useSelector(getSelectedAddress);
const isMainnet = useSelector(getIsMainnet);
const identities = useSelector(getMetaMaskIdentities);
const tokens = useSelector((state) => state.metamask.tokens);
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
const [customAddress, setCustomAddress] = useState('');
const [customAddressError, setCustomAddressError] = useState(null);
const [nftAddressError, setNftAddressError] = useState(null);
const [symbolAutoFilled, setSymbolAutoFilled] = useState(false);
const [decimalAutoFilled, setDecimalAutoFilled] = useState(false);
const [mainnetTokenWarning, setMainnetTokenWarning] = useState(null);
const [customSymbol, setCustomSymbol] = useState('');
const [customSymbolError, setCustomSymbolError] = useState(null);
const [customDecimals, setCustomDecimals] = useState(0);
const [customDecimalsError, setCustomDecimalsError] = useState(null);
const [tokenStandard, setTokenStandard] = useState(TokenStandard.none);
const [forceEditSymbol, setForceEditSymbol] = useState(false);
const chainId = useSelector(getCurrentChainId);
const blockExplorerTokenLink = getTokenTrackerLink(
customAddress,
chainId,
null,
null,
{ blockExplorerUrl: rpcPrefs?.blockExplorerUrl ?? null },
);
const blockExplorerLabel = rpcPrefs?.blockExplorerUrl
? getURLHostName(blockExplorerTokenLink)
: t('etherscan');
// Min and Max decimal values
const EMPTY_ADDRESS = '0x0000000000000000000000000000000000000000';
const MIN_DECIMAL_VALUE = 0;
const MAX_DECIMAL_VALUE = 36;
const infoGetter = useRef(tokenInfoGetter());
// CONFIRMATION MODE
const trackEvent = useContext(MetaMetricsContext);
const pendingTokens = useSelector(getPendingTokens);
const handleAddTokens = useCallback(async () => {
const addedTokenValues = Object.values(pendingTokens);
await dispatch(addImportedTokens(addedTokenValues));
const firstTokenAddress = addedTokenValues?.[0].address?.toLowerCase();
addedTokenValues.forEach((pendingToken) => {
trackEvent({
event: MetaMetricsEventName.TokenAdded,
category: MetaMetricsEventCategory.Wallet,
sensitiveProperties: {
token_symbol: pendingToken.symbol,
token_contract_address: pendingToken.address,
token_decimal_precision: pendingToken.decimals,
unlisted: pendingToken.unlisted,
source_connection_method: pendingToken.isCustom
? MetaMetricsTokenEventSource.Custom
: MetaMetricsTokenEventSource.List,
token_standard: TokenStandard.ERC20,
asset_type: AssetType.token,
},
});
});
dispatch(clearPendingTokens());
if (firstTokenAddress) {
history.push(`${ASSET_ROUTE}/${firstTokenAddress}`);
}
}, [dispatch, history, pendingTokens, trackEvent]);
useEffect(() => {
const pendingTokenKeys = Object.keys(pendingTokens);
if (pendingTokenKeys.length === 0) {
return;
}
let initialSelectedTokens = {};
let initialCustomToken = {};
pendingTokenKeys.forEach((tokenAddress) => {
const token = pendingTokens[tokenAddress];
const { isCustom } = token;
if (isCustom) {
initialCustomToken = { ...token };
} else {
initialSelectedTokens = {
...selectedTokens,
[tokenAddress]: { ...token },
};
}
});
setSelectedTokens(initialSelectedTokens);
setCustomAddress(initialCustomToken.address);
setCustomSymbol(initialCustomToken.symbol);
setCustomDecimals(initialCustomToken.decimals);
}, [pendingTokens]);
const handleCustomSymbolChange = (value) => {
const symbol = value.trim();
const symbolLength = symbol.length;
let symbolError = null;
if (symbolLength <= 0 || symbolLength >= 12) {
symbolError = t('symbolBetweenZeroTwelve');
}
setCustomSymbol(symbol);
setCustomSymbolError(symbolError);
};
const handleCustomDecimalsChange = (value) => {
let decimals;
let decimalsError = null;
if (value) {
decimals = Number(value.trim());
decimalsError =
value < MIN_DECIMAL_VALUE || value > MAX_DECIMAL_VALUE
? t('decimalsMustZerotoTen')
: null;
} else {
decimals = '';
decimalsError = t('tokenDecimalFetchFailed');
}
setCustomDecimals(decimals);
setCustomDecimalsError(decimalsError);
};
const attemptToAutoFillTokenParams = async (address) => {
const { symbol = '', decimals } = await infoGetter.current(
address,
tokenList,
);
setSymbolAutoFilled(Boolean(symbol));
setDecimalAutoFilled(Boolean(decimals));
handleCustomSymbolChange(symbol || '');
handleCustomDecimalsChange(decimals);
};
const handleToggleToken = (token) => {
const { address } = token;
const selectedTokensCopy = { ...selectedTokens };
if (address in selectedTokensCopy) {
delete selectedTokensCopy[address];
} else {
selectedTokensCopy[address] = token;
}
setSelectedTokens(selectedTokensCopy);
setTokenSelectorError(null);
};
const hasError = () => {
return (
tokenSelectorError ||
customAddressError ||
customSymbolError ||
customDecimalsError ||
nftAddressError
);
};
const hasSelected = () => {
return customAddress || Object.keys(selectedTokens).length > 0;
};
const handleNext = () => {
if (hasError()) {
return;
}
if (!hasSelected()) {
setTokenSelectorError(t('mustSelectOne'));
return;
}
const tokenAddressList = Object.keys(tokenList);
const customToken = customAddress
? {
address: customAddress,
symbol: customSymbol,
decimals: customDecimals,
standard: tokenStandard,
}
: null;
dispatch(
setPendingTokens({ customToken, selectedTokens, tokenAddressList }),
);
setMode('confirm');
};
const handleCustomAddressChange = async (value) => {
const address = value.trim();
setCustomAddress(address);
setCustomAddressError(null);
setNftAddressError(null);
setSymbolAutoFilled(false);
setDecimalAutoFilled(false);
setMainnetTokenWarning(null);
const addressIsValid = isValidHexAddress(address, {
allowNonPrefixed: false,
});
const standardAddress = addHexPrefix(address).toLowerCase();
const isMainnetToken = Object.keys(STATIC_MAINNET_TOKEN_LIST).some(
(key) => key.toLowerCase() === address.toLowerCase(),
);
let standard;
if (addressIsValid) {
try {
({ standard } = await getTokenStandardAndDetails(
standardAddress,
selectedAddress,
null,
));
} catch (error) {
// ignore
}
}
const addressIsEmpty = address.length === 0 || address === EMPTY_ADDRESS;
switch (true) {
case !addressIsValid && !addressIsEmpty:
setCustomAddressError(t('invalidAddress'));
setCustomSymbol('');
setCustomDecimals(0);
setCustomSymbolError(null);
setCustomDecimalsError(null);
break;
case standard === TokenStandard.ERC1155 ||
standard === TokenStandard.ERC721:
setNftAddressError(
t('nftAddressError', [
<ButtonLink
className="import-tokens-modal__nft-address-error-link"
onClick={() => {
dispatch(showImportNftsModal());
onClose();
}}
color={TextColor.primaryDefault}
key="nftAddressError"
>
{t('importNFTPage')}
</ButtonLink>,
]),
);
break;
case isMainnetToken && !isMainnet:
setMainnetTokenWarning(t('mainnetToken'));
setCustomSymbol('');
setCustomDecimals(0);
setCustomSymbolError(null);
setCustomDecimalsError(null);
break;
case Boolean(identities[standardAddress]):
setCustomAddressError(t('personalAddressDetected'));
break;
case checkExistingAddresses(address, tokens):
setCustomAddressError(t('tokenAlreadyAdded'));
break;
default:
if (!addressIsEmpty) {
attemptToAutoFillTokenParams(address);
if (standard) {
setTokenStandard(standard);
}
}
}
};
// Determines whether to show the Search/Import or Confirm action
const isConfirming = mode === 'confirm';
return (
<Modal
isOpen
onClose={() => {
dispatch(clearPendingTokens());
onClose();
}}
className="import-tokens-modal"
>
<ModalOverlay />
<ModalContent>
<ModalHeader
onBack={isConfirming ? () => setMode('') : null}
onClose={() => {
dispatch(clearPendingTokens());
onClose();
}}
>
{t('importTokensCamelCase')}
</ModalHeader>
<Box marginTop={6}>
{isConfirming ? (
<ImportTokensModalConfirm
onBackClick={() => {
dispatch(clearPendingTokens());
setMode('');
}}
onImportClick={async () => {
await handleAddTokens();
onClose();
}}
/>
) : (
<>
<Tabs t={t}>
{showSearchTab ? (
<Tab tabKey="search" name={t('search')}>
<Box paddingTop={4} paddingBottom={4}>
{useTokenDetection ? null : (
<BannerAlert severity={Severity.Info} marginBottom={4}>
<Text>
{t('enhancedTokenDetectionAlertMessage', [
networkName,
<ButtonLink
key="token-detection-announcement"
className="import-tokens-modal__autodetect"
onClick={() => {
history.push(
`${SECURITY_ROUTE}#advanced-settings-autodetect-tokens`,
);
onClose();
}}
>
{t('enableFromSettings')}
</ButtonLink>,
])}
</Text>
</BannerAlert>
)}
<TokenSearch
onSearch={({ results = [] }) =>
setSearchResults(results)
}
error={tokenSelectorError}
tokenList={tokenList}
/>
<Box
marginTop={4}
className="import-tokens-modal__search-list"
>
<TokenList
results={searchResults}
selectedTokens={selectedTokens}
onToggleToken={(token) => handleToggleToken(token)}
/>
</Box>
</Box>
</Tab>
) : null}
<Tab tabKey="customToken" name={t('customToken')}>
<Box
padding={[2, 4, 4, 4]}
className="import-tokens-modal__custom-token-form"
>
{tokenDetectionInactiveOnNonMainnetSupportedNetwork ? (
<BannerAlert severity={Severity.Warning}>
{t(
'customTokenWarningInTokenDetectionNetworkWithTDOFF',
[
<ButtonLink
key="import-token-security-risk"
rel="noopener noreferrer"
target="_blank"
href={ZENDESK_URLS.TOKEN_SAFETY_PRACTICES}
>
{t('tokenScamSecurityRisk')}
</ButtonLink>,
<ButtonLink
type="link"
key="import-token-token-detection-announcement"
onClick={() =>
history.push(
`${SECURITY_ROUTE}#advanced-settings-autodetect-tokens`,
)
}
>
{t('inYourSettings')}
</ButtonLink>,
],
)}
</BannerAlert>
) : (
<BannerAlert
severity={
isDynamicTokenListAvailable
? Severity.Warning
: Severity.Info
}
>
{t(
isDynamicTokenListAvailable
? 'customTokenWarningInTokenDetectionNetwork'
: 'customTokenWarningInNonTokenDetectionNetwork',
[
<ButtonLink
key="import-token-fake-token-warning"
rel="noopener noreferrer"
target="_blank"
href={ZENDESK_URLS.TOKEN_SAFETY_PRACTICES}
>
{t('learnScamRisk')}
</ButtonLink>,
],
)}
</BannerAlert>
)}
<FormTextField
label={t('tokenContractAddress')}
value={customAddress}
onChange={(e) =>
handleCustomAddressChange(e.target.value)
}
helpText={
customAddressError ||
mainnetTokenWarning ||
nftAddressError
}
error={
customAddressError ||
mainnetTokenWarning ||
nftAddressError
}
autoFocus
marginTop={6}
inputProps={{
'data-testid': 'import-tokens-modal-custom-address',
}}
/>
<FormTextField
label={
<>
{t('tokenSymbol')}
{symbolAutoFilled && !forceEditSymbol && (
<ButtonLink
onClick={() => setForceEditSymbol(true)}
textAlign={TextAlign.End}
paddingInlineEnd={1}
paddingInlineStart={1}
color={TextColor.primaryDefault}
>
{t('edit')}
</ButtonLink>
)}
</>
}
value={customSymbol}
onChange={(e) => handleCustomSymbolChange(e.target.value)}
helpText={customSymbolError}
error={customSymbolError}
disabled={symbolAutoFilled && !forceEditSymbol}
marginTop={6}
inputProps={{
'data-testid': 'import-tokens-modal-custom-symbol',
}}
/>
<FormTextField
label={t('decimal')}
type="number"
value={customDecimals}
onChange={(e) =>
handleCustomDecimalsChange(e.target.value)
}
helpText={customDecimalsError}
error={customDecimalsError}
disabled={decimalAutoFilled}
min={MIN_DECIMAL_VALUE}
max={MAX_DECIMAL_VALUE}
marginTop={6}
inputProps={{
'data-testid': 'import-tokens-modal-custom-decimals',
}}
/>
{customDecimals === '' && (
<BannerAlert severity={Severity.Warning}>
<Text fontWeight={FontWeight.Bold}>
{t('tokenDecimalFetchFailed')}
</Text>
{t('verifyThisTokenDecimalOn', [
<ButtonLink
key="import-token-verify-token-decimal"
rel="noopener noreferrer"
target="_blank"
href={blockExplorerTokenLink}
>
{blockExplorerLabel}
</ButtonLink>,
])}
</BannerAlert>
)}
</Box>
</Tab>
</Tabs>
<Box paddingTop={6} paddingBottom={6}>
<ButtonPrimary
onClick={() => handleNext()}
size={Size.LG}
disabled={Boolean(hasError()) || !hasSelected()}
block
>
{t('next')}
</ButtonPrimary>
</Box>
</>
)}
</Box>
</ModalContent>
</Modal>
);
};
ImportTokensModal.propTypes = {
onClose: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,67 @@
.import-tokens-modal {
$self: &;
.tabs {
ul {
border-bottom: 0;
}
li {
width: 50%;
}
}
&__autodetect {
vertical-align: unset;
}
&__search-list {
max-width: 100%;
overflow: auto;
max-height: 200px;
}
&__custom-token-form {
input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
display: none;
}
input[type='number']:hover::-webkit-inner-spin-button {
-webkit-appearance: none;
display: none;
}
& #{$self}__decimal-warning {
margin-top: 5px;
}
}
&__token-name {
flex: 1;
}
&__token-balance {
flex: 0 0 30%;
}
&__confirm-token-list {
flex-flow: column nowrap;
&-item {
flex-flow: row nowrap;
&-wrapper {
flex-grow: 1;
&__text {
max-width: 130px;
}
}
}
}
&__nft-address-error-link {
display: contents;
font-size: inherit;
}
}

View File

@ -0,0 +1,62 @@
import React from 'react';
import { Provider } from 'react-redux';
import configureStore from '../../../store/store';
import testData from '../../../../.storybook/test-data';
import { CHAIN_IDS } from '../../../../shared/constants/network';
import { ImportTokensModal } from './import-tokens-modal';
const createStore = (chainId = CHAIN_IDS.MAINNET, useTokenDetection = true) => {
return configureStore({
...testData,
metamask: {
...testData.metamask,
useTokenDetection,
providerConfig: { chainId },
},
});
};
export default {
title: 'Components/Multichain/ImportTokensModal',
component: ImportTokensModal,
argTypes: {
onClose: {
action: 'onClose',
},
},
};
export const DefaultStory = (args) => <ImportTokensModal {...args} />;
DefaultStory.decorators = [
(Story) => (
<Provider store={createStore()}>
<Story />
</Provider>
),
];
DefaultStory.storyName = 'Default';
export const CustomImportOnlyStory = (args) => <ImportTokensModal {...args} />;
CustomImportOnlyStory.decorators = [
(Story) => (
<Provider store={createStore(CHAIN_IDS.GOERLI)}>
<Story />
</Provider>
),
];
CustomImportOnlyStory.storyName = 'Custom Import Only';
export const TokenDetectionDisabledStory = (args) => (
<ImportTokensModal {...args} />
);
TokenDetectionDisabledStory.decorators = [
(Story) => (
<Provider store={createStore(CHAIN_IDS.MAINNET, false)}>
<Story />
</Provider>
),
];
TokenDetectionDisabledStory.storyName = 'Token Detection Disabled';

View File

@ -0,0 +1,200 @@
import React from 'react';
import { fireEvent } from '@testing-library/react';
import { renderWithProvider } from '../../../../test/lib/render-helpers';
import configureStore from '../../../store/store';
import {
setPendingTokens,
clearPendingTokens,
getTokenStandardAndDetails,
} from '../../../store/actions';
import mockState from '../../../../test/data/mock-state.json';
import { TokenStandard } from '../../../../shared/constants/transaction';
import { ImportTokensModal } from '.';
jest.mock('../../../store/actions', () => ({
getTokenStandardAndDetails: jest
.fn()
.mockImplementation(() => Promise.resolve({ standard: 'ERC20' })),
setPendingTokens: jest
.fn()
.mockImplementation(() => ({ type: 'SET_PENDING_TOKENS' })),
clearPendingTokens: jest
.fn()
.mockImplementation(() => ({ type: 'CLEAR_PENDING_TOKENS' })),
}));
describe('ImportTokensModal', () => {
const render = (metamaskStateChanges = {}, onClose = jest.fn()) => {
const store = configureStore({
...mockState,
metamask: {
...mockState.metamask,
...metamaskStateChanges,
},
});
return renderWithProvider(<ImportTokensModal onClose={onClose} />, store);
};
describe('Search', () => {
it('renders expected elements', () => {
const { getByText, getByPlaceholderText } = render();
expect(
getByText(`Add the tokens you've acquired using MetaMask`),
).toBeInTheDocument();
expect(getByText('Next')).toBeDisabled();
expect(getByPlaceholderText('Search')).toBeInTheDocument();
});
it('shows the token detection notice when setting is off', () => {
const { getByText } = render({ useTokenDetection: false });
expect(getByText('Enable it from Settings.')).toBeInTheDocument();
});
});
describe('Custom Token', () => {
it('add custom token button is disabled when no fields are populated', () => {
const { getByText } = render();
const customTokenButton = getByText('Custom token');
fireEvent.click(customTokenButton);
const submit = getByText('Next');
expect(submit).toBeDisabled();
});
it('edits token address', () => {
const { getByText, getByTestId } = render();
const customTokenButton = getByText('Custom token');
fireEvent.click(customTokenButton);
const tokenAddress = '0x617b3f8050a0BD94b6b1da02B4384eE5B4DF13F4';
const event = { target: { value: tokenAddress } };
fireEvent.change(
getByTestId('import-tokens-modal-custom-address'),
event,
);
expect(
getByTestId('import-tokens-modal-custom-address').value,
).toStrictEqual(tokenAddress);
});
it('edits token symbol', () => {
const { getByText, getByTestId } = render();
const customTokenButton = getByText('Custom token');
fireEvent.click(customTokenButton);
const tokenSymbol = 'META';
const event = { target: { value: tokenSymbol } };
fireEvent.change(getByTestId('import-tokens-modal-custom-symbol'), event);
expect(
getByTestId('import-tokens-modal-custom-symbol').value,
).toStrictEqual(tokenSymbol);
});
it('edits token decimal precision', () => {
const { getByText, getByTestId } = render();
const customTokenButton = getByText('Custom token');
fireEvent.click(customTokenButton);
const tokenPrecision = '2';
const event = { target: { value: tokenPrecision } };
fireEvent.change(
getByTestId('import-tokens-modal-custom-decimals'),
event,
);
expect(
getByTestId('import-tokens-modal-custom-decimals').value,
).toStrictEqual(tokenPrecision);
});
it('adds custom tokens successfully', async () => {
const { getByText, getByTestId } = render({ tokens: [], tokenList: {} });
const customTokenButton = getByText('Custom token');
fireEvent.click(customTokenButton);
expect(getByText('Next')).toBeDisabled();
const tokenAddress = '0x617b3f8050a0BD94b6b1da02B4384eE5B4DF13F4';
await fireEvent.change(
getByTestId('import-tokens-modal-custom-address'),
{
target: { value: tokenAddress },
},
);
expect(getByText('Next')).not.toBeDisabled();
const tokenSymbol = 'META';
fireEvent.change(getByTestId('import-tokens-modal-custom-symbol'), {
target: { value: tokenSymbol },
});
expect(getByTestId('import-tokens-modal-custom-symbol').value).toBe(
'META',
);
const tokenPrecision = '2';
fireEvent.change(getByTestId('import-tokens-modal-custom-decimals'), {
target: { value: tokenPrecision },
});
expect(getByText('Next')).not.toBeDisabled();
fireEvent.click(getByText('Next'));
expect(setPendingTokens).toHaveBeenCalledWith({
customToken: {
address: tokenAddress,
decimals: Number(tokenPrecision),
standard: TokenStandard.ERC20,
symbol: tokenSymbol,
},
selectedTokens: {},
tokenAddressList: [],
});
expect(getByText('Import')).toBeInTheDocument();
});
it('cancels out of import token flow', () => {
const onClose = jest.fn();
render({}, onClose);
fireEvent.click(document.querySelector('button[aria-label="Close"]'));
expect(clearPendingTokens).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('sets and error when a token is an NFT', async () => {
getTokenStandardAndDetails.mockImplementation(() =>
Promise.resolve({ standard: TokenStandard.ERC721 }),
);
const { getByText, getByTestId } = render();
const customTokenButton = getByText('Custom token');
fireEvent.click(customTokenButton);
const submit = getByText('Next');
expect(submit).toBeDisabled();
const tokenAddress = '0x617b3f8050a0BD94b6b1da02B4384eE5B4DF13F4';
await fireEvent.change(
getByTestId('import-tokens-modal-custom-address'),
{
target: { value: tokenAddress },
},
);
expect(submit).toBeDisabled();
// The last part of this error message won't be found by getByText because it is wrapped as a link.
const errorMessage = getByText('This token is an NFT. Add on the');
expect(errorMessage).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1 @@
export { ImportTokensModal } from './import-tokens-modal';

View File

@ -18,3 +18,4 @@ export { CreateAccount } from './create-account';
export { ImportAccount } from './import-account';
export { ImportNftsModal } from './import-nfts-modal';
export { AccountDetailsMenuItem, ViewExplorerMenuItem } from './menu-items';
export { ImportTokensModal } from './import-tokens-modal';

View File

@ -19,3 +19,4 @@
@import 'network-list-menu/';
@import 'product-tour-popover/product-tour-popover';
@import 'nft-item/nft-item';
@import 'import-tokens-modal/import-tokens-modal'

View File

@ -88,19 +88,6 @@ export default class PageContainer extends PureComponent {
return null;
}
getTabSubmitText() {
const { tabsComponent } = this.props;
const { activeTabIndex } = this.state;
if (tabsComponent) {
let { children } = tabsComponent.props;
children = children.filter(Boolean);
if (children[activeTabIndex]?.key === 'custom-tab') {
return this.context.t('addCustomToken');
}
}
return null;
}
render() {
const {
title,
@ -118,7 +105,6 @@ export default class PageContainer extends PureComponent {
headerCloseText,
hideCancel,
} = this.props;
const tabSubmitText = this.getTabSubmitText();
return (
<div className="page-container">
<PageContainerHeader
@ -139,7 +125,7 @@ export default class PageContainer extends PureComponent {
cancelText={cancelText}
hideCancel={hideCancel}
onSubmit={onSubmit}
submitText={tabSubmitText || submitText}
submitText={submitText}
disabled={disabled}
/>
</div>

View File

@ -90,4 +90,8 @@
transform: rotate(45deg);
box-shadow: var(--shadow-size-lg) var(--color-shadow-default);
}
&-container .page-container {
width: auto;
}
}

View File

@ -28,6 +28,7 @@ interface AppState {
networkDropdownOpen: boolean;
importNftsModalOpen: boolean;
showIpfsModalOpen: boolean;
importTokensModalOpen: boolean;
accountDetail: {
subview?: string;
accountExport?: string;
@ -98,6 +99,7 @@ const initialState: AppState = {
networkDropdownOpen: false,
importNftsModalOpen: false,
showIpfsModalOpen: false,
importTokensModalOpen: false,
accountDetail: {
privateKey: '',
},
@ -191,6 +193,18 @@ export default function reduceApp(
showIpfsModalOpen: false,
};
case actionConstants.IMPORT_TOKENS_POPOVER_OPEN:
return {
...appState,
importTokensModalOpen: true,
};
case actionConstants.IMPORT_TOKENS_POPOVER_CLOSE:
return {
...appState,
importTokensModalOpen: false,
};
// alert methods
case actionConstants.ALERT_OPEN:
return {

View File

@ -1,150 +0,0 @@
import React, { useCallback, useContext, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import {
ASSET_ROUTE,
IMPORT_TOKEN_ROUTE,
} from '../../helpers/constants/routes';
import Button from '../../components/ui/button';
import Identicon from '../../components/ui/identicon';
import TokenBalance from '../../components/ui/token-balance';
import { I18nContext } from '../../contexts/i18n';
import { MetaMetricsContext } from '../../contexts/metametrics';
import { getMostRecentOverviewPage } from '../../ducks/history/history';
import { getPendingTokens } from '../../ducks/metamask/metamask';
import { addImportedTokens, clearPendingTokens } from '../../store/actions';
import {
MetaMetricsEventCategory,
MetaMetricsEventName,
MetaMetricsTokenEventSource,
} from '../../../shared/constants/metametrics';
import {
AssetType,
TokenStandard,
} from '../../../shared/constants/transaction';
const getTokenName = (name, symbol) => {
return name === undefined ? symbol : `${name} (${symbol})`;
};
const ConfirmImportToken = () => {
const t = useContext(I18nContext);
const dispatch = useDispatch();
const history = useHistory();
const trackEvent = useContext(MetaMetricsContext);
const mostRecentOverviewPage = useSelector(getMostRecentOverviewPage);
const pendingTokens = useSelector(getPendingTokens);
const handleAddTokens = useCallback(async () => {
const addedTokenValues = Object.values(pendingTokens);
await dispatch(addImportedTokens(addedTokenValues));
const firstTokenAddress = addedTokenValues?.[0].address?.toLowerCase();
addedTokenValues.forEach((pendingToken) => {
trackEvent({
event: MetaMetricsEventName.TokenAdded,
category: MetaMetricsEventCategory.Wallet,
sensitiveProperties: {
token_symbol: pendingToken.symbol,
token_contract_address: pendingToken.address,
token_decimal_precision: pendingToken.decimals,
unlisted: pendingToken.unlisted,
source: pendingToken.isCustom
? MetaMetricsTokenEventSource.Custom
: MetaMetricsTokenEventSource.List,
token_standard: TokenStandard.ERC20,
asset_type: AssetType.token,
},
});
});
dispatch(clearPendingTokens());
if (firstTokenAddress) {
history.push(`${ASSET_ROUTE}/${firstTokenAddress}`);
} else {
history.push(mostRecentOverviewPage);
}
}, [dispatch, history, mostRecentOverviewPage, pendingTokens, trackEvent]);
useEffect(() => {
if (Object.keys(pendingTokens).length === 0) {
history.push(mostRecentOverviewPage);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="page-container">
<div className="page-container__header">
<div className="page-container__title">
{t('importTokensCamelCase')}
</div>
<div className="page-container__subtitle">
{t('likeToImportTokens')}
</div>
</div>
<div className="page-container__content">
<div className="confirm-import-token">
<div className="confirm-import-token__header">
<div className="confirm-import-token__token">{t('token')}</div>
<div className="confirm-import-token__balance">{t('balance')}</div>
</div>
<div className="confirm-import-token__token-list">
{Object.entries(pendingTokens).map(([address, token]) => {
const { name, symbol } = token;
return (
<div
className="confirm-import-token__token-list-item"
key={address}
>
<div className="confirm-import-token__token confirm-import-token__data">
<Identicon
className="confirm-import-token__token-icon"
diameter={48}
address={address}
/>
<div className="confirm-import-token__name">
{getTokenName(name, symbol)}
</div>
</div>
<div className="confirm-import-token__balance">
<TokenBalance token={token} />
</div>
</div>
);
})}
</div>
</div>
</div>
<div className="page-container__footer">
<footer>
<Button
type="secondary"
large
className="page-container__footer-button"
onClick={() => {
dispatch(clearPendingTokens());
history.push(IMPORT_TOKEN_ROUTE);
}}
>
{t('back')}
</Button>
<Button
type="primary"
large
className="page-container__footer-button"
onClick={handleAddTokens}
>
{t('importTokensCamelCase')}
</Button>
</footer>
</div>
</div>
);
};
export default ConfirmImportToken;

View File

@ -1,46 +0,0 @@
/* eslint-disable react/prop-types */
import React, { useEffect } from 'react';
import { store, getNewState } from '../../../.storybook/preview';
import { tokens } from '../../../.storybook/initial-states/approval-screens/add-token';
import { updateMetamaskState } from '../../store/actions';
import ConfirmAddToken from '.';
export default {
title: 'Pages/ConfirmImportToken',
argTypes: {
pendingTokens: {
control: 'object',
table: { category: 'Data' },
},
},
};
const PageSet = ({ children, pendingTokens }) => {
const { metamask: state } = store.getState();
useEffect(() => {
store.dispatch(
updateMetamaskState(
getNewState(state, {
pendingTokens,
}),
),
);
}, [state, pendingTokens]);
return children;
};
export const DefaultStory = ({ pendingTokens }) => {
return (
<PageSet pendingTokens={pendingTokens}>
<ConfirmAddToken />
</PageSet>
);
};
DefaultStory.args = {
pendingTokens: { ...tokens },
};
DefaultStory.storyName = 'Default';

View File

@ -1,128 +0,0 @@
import React from 'react';
import reactRouterDom from 'react-router-dom';
import { fireEvent, screen } from '@testing-library/react';
import {
ASSET_ROUTE,
IMPORT_TOKEN_ROUTE,
} from '../../helpers/constants/routes';
import { addImportedTokens, clearPendingTokens } from '../../store/actions';
import configureStore from '../../store/store';
import { renderWithProvider } from '../../../test/jest';
import ConfirmImportToken from '.';
const MOCK_PENDING_TOKENS = {
'0x6b175474e89094c44da98b954eedeac495271d0f': {
address: '0x6b175474e89094c44da98b954eedeac495271d0f',
symbol: 'META',
decimals: 18,
image: 'metamark.svg',
},
'0xB8c77482e45F1F44dE1745F52C74426C631bDD52': {
address: '0xB8c77482e45F1F44dE1745F52C74426C631bDD52',
symbol: '0X',
decimals: 18,
image: '0x.svg',
},
};
jest.mock('../../store/actions', () => ({
addImportedTokens: jest.fn().mockReturnValue({ type: 'test' }),
clearPendingTokens: jest
.fn()
.mockReturnValue({ type: 'CLEAR_PENDING_TOKENS' }),
}));
const renderComponent = (mockPendingTokens = MOCK_PENDING_TOKENS) => {
const store = configureStore({
metamask: {
pendingTokens: { ...mockPendingTokens },
providerConfig: { chainId: '0x1' },
},
history: {
mostRecentOverviewPage: '/',
},
});
return renderWithProvider(<ConfirmImportToken />, store);
};
describe('ConfirmImportToken Component', () => {
const mockHistoryPush = jest.fn();
beforeEach(() => {
jest
.spyOn(reactRouterDom, 'useHistory')
.mockImplementation()
.mockReturnValue({ push: mockHistoryPush });
});
afterEach(() => {
jest.clearAllMocks();
});
it('should render', () => {
renderComponent();
const [title, importTokensBtn] = screen.queryAllByText('Import tokens');
expect(title).toBeInTheDocument(title);
expect(
screen.getByText('Would you like to import these tokens?'),
).toBeInTheDocument();
expect(screen.getByText('Token')).toBeInTheDocument();
expect(screen.getByText('Balance')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument();
expect(importTokensBtn).toBeInTheDocument();
});
it('should render the list of tokens', () => {
renderComponent();
Object.values(MOCK_PENDING_TOKENS).forEach((token) => {
expect(screen.getByText(token.symbol)).toBeInTheDocument();
});
});
it('should go to "IMPORT_TOKEN_ROUTE" route when clicking the "Back" button', async () => {
renderComponent();
const backBtn = screen.getByRole('button', { name: 'Back' });
await fireEvent.click(backBtn);
expect(mockHistoryPush).toHaveBeenCalledTimes(1);
expect(mockHistoryPush).toHaveBeenCalledWith(IMPORT_TOKEN_ROUTE);
});
it('should dispatch clearPendingTokens and redirect to the first token page when clicking the "Import tokens" button', async () => {
const mockFirstPendingTokenAddress =
'0xe83cccfabd4ed148903bf36d4283ee7c8b3494d1';
const mockPendingTokens = {
[mockFirstPendingTokenAddress]: {
address: mockFirstPendingTokenAddress,
symbol: 'CVL',
decimals: 18,
image: 'CVL_token.svg',
},
'0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e': {
address: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e',
symbol: 'GLA',
decimals: 18,
image: 'gladius.svg',
},
};
renderComponent(mockPendingTokens);
const importTokensBtn = screen.getByRole('button', {
name: 'Import tokens',
});
await fireEvent.click(importTokensBtn);
expect(addImportedTokens).toHaveBeenCalled();
expect(clearPendingTokens).toHaveBeenCalled();
expect(mockHistoryPush).toHaveBeenCalledTimes(1);
expect(mockHistoryPush).toHaveBeenCalledWith(
`${ASSET_ROUTE}/${mockFirstPendingTokenAddress}`,
);
});
});

View File

@ -1,3 +0,0 @@
import ConfirmImportToken from './confirm-import-token';
export default ConfirmImportToken;

View File

@ -1,50 +0,0 @@
.confirm-import-token {
padding: 16px;
&__header {
@include H7;
display: flex;
}
&__token {
flex: 1;
min-width: 0;
}
&__balance {
flex: 0 0 30%;
min-width: 0;
}
&__token-list {
display: flex;
flex-flow: column nowrap;
}
&__token-list-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-top: 8px;
box-sizing: border-box;
}
&__data {
display: flex;
align-items: center;
padding: 8px;
}
&__name {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__token-icon {
margin-right: 12px;
flex: 0 0 auto;
}
}

View File

@ -1,31 +0,0 @@
import { Story, Canvas, ArgsTable } from '@storybook/addon-docs';
import ImportToken from './import-token.component';
import testData from '../../../.storybook/test-data';
import configureStore from '../../store/store';
export const PersonalAddress = () => <code>{configureStore(testData).getState().metamask.selectedAddress}</code>;
# ImportToken
The `ImportToken` component allows a user to import custom tokens in one of two ways:
1. By searching for one
2. By importing one by `Token Contract Address`
<Canvas>
<Story id="pages-swaps-importtoken--default-story" />
</Canvas>
## Example inputs
An example input that works, to enable the `Add custom token` button is `0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`.
### Personal address error
To show the personal address detected error, input the address <PersonalAddress/> in the `Token Contract Address` field.
## Props
<ArgsTable of={ImportToken} />

View File

@ -1,659 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { getTokenTrackerLink } from '@metamask/etherscan-link';
import ZENDESK_URLS from '../../helpers/constants/zendesk-url';
import {
checkExistingAddresses,
getURLHostName,
} from '../../helpers/utils/util';
import { tokenInfoGetter } from '../../helpers/utils/token-util';
import {
CONFIRM_IMPORT_TOKEN_ROUTE,
SECURITY_ROUTE,
} from '../../helpers/constants/routes';
import TextField from '../../components/ui/text-field';
import PageContainer from '../../components/ui/page-container';
import { Tabs, Tab } from '../../components/ui/tabs';
import { addHexPrefix } from '../../../app/scripts/lib/util';
import { isValidHexAddress } from '../../../shared/modules/hexstring-utils';
import ActionableMessage from '../../components/ui/actionable-message/actionable-message';
import Typography from '../../components/ui/typography';
import {
TypographyVariant,
FONT_WEIGHT,
} from '../../helpers/constants/design-system';
import Button from '../../components/ui/button';
import { TokenStandard } from '../../../shared/constants/transaction';
import { STATIC_MAINNET_TOKEN_LIST } from '../../../shared/constants/tokens';
import TokenSearch from './token-search';
import TokenList from './token-list';
const emptyAddr = '0x0000000000000000000000000000000000000000';
const MIN_DECIMAL_VALUE = 0;
const MAX_DECIMAL_VALUE = 36;
class ImportToken extends Component {
static contextTypes = {
t: PropTypes.func,
};
static propTypes = {
/**
* History object of the router.
*/
history: PropTypes.object,
/**
* Set the state of `pendingTokens`, called when adding a token.
*/
setPendingTokens: PropTypes.func,
/**
* The current list of pending tokens to be added.
*/
pendingTokens: PropTypes.object,
/**
* Clear the list of pending tokens. Called when closing the modal.
*/
clearPendingTokens: PropTypes.func,
/**
* Clear the list of pending tokens. Called when closing the modal.
*/
showImportNftsModal: PropTypes.func,
/**
* The list of already added tokens.
*/
tokens: PropTypes.array,
/**
* The identities/accounts that are currently added to the wallet.
*/
identities: PropTypes.object,
/**
* Boolean flag that shows/hides the search tab.
*/
showSearchTab: PropTypes.bool.isRequired,
/**
* The most recent overview page route, which is 'navigated' to when closing the modal.
*/
mostRecentOverviewPage: PropTypes.string.isRequired,
/**
* The active chainId in use.
*/
chainId: PropTypes.string,
/**
* The rpc preferences to use for the current provider.
*/
rpcPrefs: PropTypes.object,
/**
* The list of tokens available for search.
*/
tokenList: PropTypes.object,
/**
* Boolean flag indicating whether token detection is enabled or not.
* When disabled, shows an information alert in the search tab informing the
* user of the availability of this feature.
*/
useTokenDetection: PropTypes.bool,
/**
* Function called to fetch information about the token standard and
* details, see `actions.js`.
*/
getTokenStandardAndDetails: PropTypes.func,
/**
* The currently selected active address.
*/
selectedAddress: PropTypes.string,
isDynamicTokenListAvailable: PropTypes.bool.isRequired,
tokenDetectionInactiveOnNonMainnetSupportedNetwork:
PropTypes.bool.isRequired,
networkName: PropTypes.string.isRequired,
};
static defaultProps = {
tokenList: {},
};
state = {
customAddress: '',
customSymbol: '',
customDecimals: 0,
searchResults: [],
selectedTokens: {},
standard: TokenStandard.NONE,
tokenSelectorError: null,
customAddressError: null,
customSymbolError: null,
customDecimalsError: null,
nftAddressError: null,
forceEditSymbol: false,
symbolAutoFilled: false,
decimalAutoFilled: false,
mainnetTokenWarning: null,
};
componentDidMount() {
this.tokenInfoGetter = tokenInfoGetter();
const { pendingTokens = {} } = this.props;
const pendingTokenKeys = Object.keys(pendingTokens);
if (pendingTokenKeys.length > 0) {
let selectedTokens = {};
let customToken = {};
pendingTokenKeys.forEach((tokenAddress) => {
const token = pendingTokens[tokenAddress];
const { isCustom } = token;
if (isCustom) {
customToken = { ...token };
} else {
selectedTokens = { ...selectedTokens, [tokenAddress]: { ...token } };
}
});
const {
address: customAddress = '',
symbol: customSymbol = '',
decimals: customDecimals = 0,
} = customToken;
this.setState({
selectedTokens,
customAddress,
customSymbol,
customDecimals,
});
}
}
handleToggleToken(token) {
const { address } = token;
const { selectedTokens = {} } = this.state;
const selectedTokensCopy = { ...selectedTokens };
if (address in selectedTokensCopy) {
delete selectedTokensCopy[address];
} else {
selectedTokensCopy[address] = token;
}
this.setState({
selectedTokens: selectedTokensCopy,
tokenSelectorError: null,
});
}
hasError() {
const {
tokenSelectorError,
customAddressError,
customSymbolError,
customDecimalsError,
nftAddressError,
} = this.state;
return (
tokenSelectorError ||
customAddressError ||
customSymbolError ||
customDecimalsError ||
nftAddressError
);
}
hasSelected() {
const { customAddress = '', selectedTokens = {} } = this.state;
return customAddress || Object.keys(selectedTokens).length > 0;
}
handleNext() {
if (this.hasError()) {
return;
}
if (!this.hasSelected()) {
this.setState({ tokenSelectorError: this.context.t('mustSelectOne') });
return;
}
const { setPendingTokens, history, tokenList } = this.props;
const tokenAddressList = Object.keys(tokenList);
const {
customAddress: address,
customSymbol: symbol,
customDecimals: decimals,
selectedTokens,
standard,
} = this.state;
const customToken = {
address,
symbol,
decimals,
standard,
};
setPendingTokens({ customToken, selectedTokens, tokenAddressList });
history.push(CONFIRM_IMPORT_TOKEN_ROUTE);
}
async attemptToAutoFillTokenParams(address) {
const { tokenList } = this.props;
const { symbol = '', decimals } = await this.tokenInfoGetter(
address,
tokenList,
);
const symbolAutoFilled = Boolean(symbol);
const decimalAutoFilled = Boolean(decimals);
this.setState({ symbolAutoFilled, decimalAutoFilled });
this.handleCustomSymbolChange(symbol || '');
this.handleCustomDecimalsChange(decimals);
}
async handleCustomAddressChange(value) {
const customAddress = value.trim();
this.setState({
customAddress,
customAddressError: null,
nftAddressError: null,
tokenSelectorError: null,
symbolAutoFilled: false,
decimalAutoFilled: false,
mainnetTokenWarning: null,
});
const addressIsValid = isValidHexAddress(customAddress, {
allowNonPrefixed: false,
});
const standardAddress = addHexPrefix(customAddress).toLowerCase();
const isMainnetToken = Object.keys(STATIC_MAINNET_TOKEN_LIST).some(
(key) => key.toLowerCase() === customAddress.toLowerCase(),
);
const isMainnetNetwork = this.props.chainId === '0x1';
let standard;
if (addressIsValid) {
try {
({ standard } = await this.props.getTokenStandardAndDetails(
standardAddress,
this.props.selectedAddress,
));
} catch (error) {
// ignore
}
}
const addressIsEmpty =
customAddress.length === 0 || customAddress === emptyAddr;
switch (true) {
case !addressIsValid && !addressIsEmpty:
this.setState({
customAddressError: this.context.t('invalidAddress'),
customSymbol: '',
customDecimals: 0,
customSymbolError: null,
customDecimalsError: null,
});
break;
case standard === 'ERC1155' || standard === 'ERC721':
this.setState({
nftAddressError: this.context.t('nftAddressError', [
<a
className="import-token__nft-address-error-link"
onClick={() => {
this.props.showImportNftsModal();
}}
key="nftAddressError"
>
{this.context.t('importNFTPage')}
</a>,
]),
});
break;
case isMainnetToken && !isMainnetNetwork:
this.setState({
mainnetTokenWarning: this.context.t('mainnetToken'),
customSymbol: '',
customDecimals: 0,
customSymbolError: null,
customDecimalsError: null,
});
break;
case Boolean(this.props.identities[standardAddress]):
this.setState({
customAddressError: this.context.t('personalAddressDetected'),
});
break;
case checkExistingAddresses(customAddress, this.props.tokens):
this.setState({
customAddressError: this.context.t('tokenAlreadyAdded'),
});
break;
default:
if (!addressIsEmpty) {
this.attemptToAutoFillTokenParams(customAddress);
if (standard) {
this.setState({ standard });
}
}
}
}
handleCustomSymbolChange(value) {
const customSymbol = value.trim();
const symbolLength = customSymbol.length;
let customSymbolError = null;
if (symbolLength <= 0 || symbolLength >= 12) {
customSymbolError = this.context.t('symbolBetweenZeroTwelve');
}
this.setState({ customSymbol, customSymbolError });
}
handleCustomDecimalsChange(value) {
let customDecimals;
let customDecimalsError = null;
if (value) {
customDecimals = Number(value.trim());
customDecimalsError =
value < MIN_DECIMAL_VALUE || value > MAX_DECIMAL_VALUE
? this.context.t('decimalsMustZerotoTen')
: null;
} else {
customDecimals = '';
customDecimalsError = this.context.t('tokenDecimalFetchFailed');
}
this.setState({ customDecimals, customDecimalsError });
}
renderCustomTokenForm() {
const { t } = this.context;
const {
customAddress,
customSymbol,
customDecimals,
customAddressError,
customSymbolError,
customDecimalsError,
forceEditSymbol,
symbolAutoFilled,
decimalAutoFilled,
mainnetTokenWarning,
nftAddressError,
} = this.state;
const {
chainId,
rpcPrefs,
isDynamicTokenListAvailable,
tokenDetectionInactiveOnNonMainnetSupportedNetwork,
history,
} = this.props;
const blockExplorerTokenLink = getTokenTrackerLink(
customAddress,
chainId,
null,
null,
{ blockExplorerUrl: rpcPrefs?.blockExplorerUrl ?? null },
);
const blockExplorerLabel = rpcPrefs?.blockExplorerUrl
? getURLHostName(blockExplorerTokenLink)
: t('etherscan');
return (
<div className="import-token__custom-token-form">
{tokenDetectionInactiveOnNonMainnetSupportedNetwork ? (
<ActionableMessage
type="warning"
message={t('customTokenWarningInTokenDetectionNetworkWithTDOFF', [
<Button
type="link"
key="import-token-security-risk"
className="import-token__link"
rel="noopener noreferrer"
target="_blank"
href={ZENDESK_URLS.TOKEN_SAFETY_PRACTICES}
>
{t('tokenScamSecurityRisk')}
</Button>,
<Button
type="link"
key="import-token-token-detection-announcement"
className="import-token__link"
onClick={() =>
history.push(`${SECURITY_ROUTE}#token-description`)
}
>
{t('inYourSettings')}
</Button>,
])}
withRightButton
useIcon
iconFillColor="var(--color-warning-default)"
/>
) : (
<ActionableMessage
type={isDynamicTokenListAvailable ? 'warning' : 'default'}
message={t(
isDynamicTokenListAvailable
? 'customTokenWarningInTokenDetectionNetwork'
: 'customTokenWarningInNonTokenDetectionNetwork',
[
<Button
type="link"
key="import-token-fake-token-warning"
className="import-token__link"
rel="noopener noreferrer"
target="_blank"
href={ZENDESK_URLS.TOKEN_SAFETY_PRACTICES}
>
{t('learnScamRisk')}
</Button>,
],
)}
withRightButton
useIcon
iconFillColor={
isDynamicTokenListAvailable
? 'var(--color-warning-default)'
: 'var(--color-info-default)'
}
/>
)}
<TextField
id="custom-address"
label={t('tokenContractAddress')}
type="text"
value={customAddress}
onChange={(e) => this.handleCustomAddressChange(e.target.value)}
error={customAddressError || mainnetTokenWarning || nftAddressError}
fullWidth
autoFocus
margin="normal"
/>
<TextField
id="custom-symbol"
label={
<div className="import-token__custom-symbol__label-wrapper">
<span className="import-token__custom-symbol__label">
{t('tokenSymbol')}
</span>
{symbolAutoFilled && !forceEditSymbol && (
<div
className="import-token__custom-symbol__edit"
onClick={() => this.setState({ forceEditSymbol: true })}
>
{t('edit')}
</div>
)}
</div>
}
type="text"
value={customSymbol}
onChange={(e) => this.handleCustomSymbolChange(e.target.value)}
error={customSymbolError}
fullWidth
margin="normal"
disabled={symbolAutoFilled && !forceEditSymbol}
/>
<TextField
id="custom-decimals"
label={t('decimal')}
type="number"
value={customDecimals}
onChange={(e) => this.handleCustomDecimalsChange(e.target.value)}
error={customDecimals ? customDecimalsError : null}
fullWidth
margin="normal"
disabled={decimalAutoFilled}
min={MIN_DECIMAL_VALUE}
max={MAX_DECIMAL_VALUE}
/>
{customDecimals === '' && (
<ActionableMessage
message={
<>
<Typography
variant={TypographyVariant.H7}
fontWeight={FONT_WEIGHT.BOLD}
>
{t('tokenDecimalFetchFailed')}
</Typography>
<Typography
variant={TypographyVariant.H7}
fontWeight={FONT_WEIGHT.NORMAL}
>
{t('verifyThisTokenDecimalOn', [
<Button
type="link"
key="import-token-verify-token-decimal"
className="import-token__link"
rel="noopener noreferrer"
target="_blank"
href={blockExplorerTokenLink}
>
{blockExplorerLabel}
</Button>,
])}
</Typography>
</>
}
type="warning"
withRightButton
className="import-token__decimal-warning"
/>
)}
</div>
);
}
renderSearchToken() {
const { t } = this.context;
const { tokenList, history, useTokenDetection, networkName } = this.props;
const { tokenSelectorError, selectedTokens, searchResults } = this.state;
return (
<div className="import-token__search-token">
{!useTokenDetection && (
<ActionableMessage
message={t('enhancedTokenDetectionAlertMessage', [
networkName,
<Button
type="link"
key="token-detection-announcement"
className="import-token__link"
onClick={() =>
history.push(`${SECURITY_ROUTE}#token-description`)
}
>
{t('enableFromSettings')}
</Button>,
])}
withRightButton
useIcon
iconFillColor="var(--color-primary-default)"
className="import-token__token-detection-announcement"
/>
)}
<TokenSearch
onSearch={({ results = [] }) =>
this.setState({ searchResults: results })
}
error={tokenSelectorError}
tokenList={tokenList}
/>
<div className="import-token__token-list">
<TokenList
results={searchResults}
selectedTokens={selectedTokens}
onToggleToken={(token) => this.handleToggleToken(token)}
/>
</div>
</div>
);
}
renderTabs() {
const { t } = this.context;
const { showSearchTab } = this.props;
const tabs = [];
if (showSearchTab) {
tabs.push(
<Tab name={t('search')} key="search-tab" tabKey="search">
{this.renderSearchToken()}
</Tab>,
);
}
tabs.push(
<Tab name={t('customToken')} key="custom-tab" tabKey="customToken">
{this.renderCustomTokenForm()}
</Tab>,
);
return <Tabs>{tabs}</Tabs>;
}
render() {
const { history, clearPendingTokens, mostRecentOverviewPage } = this.props;
return (
<PageContainer
title={this.context.t('importTokensCamelCase')}
tabsComponent={this.renderTabs()}
onSubmit={() => this.handleNext()}
hideCancel
disabled={Boolean(this.hasError()) || !this.hasSelected()}
onClose={() => {
clearPendingTokens();
history.push(mostRecentOverviewPage);
}}
/>
);
}
}
export default ImportToken;

View File

@ -1,68 +0,0 @@
import { connect } from 'react-redux';
import {
setPendingTokens,
clearPendingTokens,
getTokenStandardAndDetails,
showImportNftsModal,
} from '../../store/actions';
import { getMostRecentOverviewPage } from '../../ducks/history/history';
import { getProviderConfig } from '../../ducks/metamask/metamask';
import {
getRpcPrefsForCurrentProvider,
getIsTokenDetectionSupported,
getTokenDetectionSupportNetworkByChainId,
getIsTokenDetectionInactiveOnMainnet,
getIsDynamicTokenListAvailable,
getIstokenDetectionInactiveOnNonMainnetSupportedNetwork,
getTokenList,
} from '../../selectors/selectors';
import ImportToken from './import-token.component';
const mapStateToProps = (state) => {
const {
metamask: {
identities,
tokens,
pendingTokens,
useTokenDetection,
selectedAddress,
},
} = state;
const { chainId } = getProviderConfig(state);
const isTokenDetectionInactiveOnMainnet =
getIsTokenDetectionInactiveOnMainnet(state);
const showSearchTab =
getIsTokenDetectionSupported(state) ||
isTokenDetectionInactiveOnMainnet ||
Boolean(process.env.IN_TEST);
return {
identities,
mostRecentOverviewPage: getMostRecentOverviewPage(state),
tokens,
pendingTokens,
showSearchTab,
chainId,
rpcPrefs: getRpcPrefsForCurrentProvider(state),
tokenList: getTokenList(state),
useTokenDetection,
selectedAddress,
isDynamicTokenListAvailable: getIsDynamicTokenListAvailable(state),
networkName: getTokenDetectionSupportNetworkByChainId(state),
tokenDetectionInactiveOnNonMainnetSupportedNetwork:
getIstokenDetectionInactiveOnNonMainnetSupportedNetwork(state),
};
};
const mapDispatchToProps = (dispatch) => {
return {
setPendingTokens: (tokens) => dispatch(setPendingTokens(tokens)),
clearPendingTokens: () => dispatch(clearPendingTokens()),
showImportNftsModal: () => dispatch(showImportNftsModal()),
getTokenStandardAndDetails: (address, selectedAddress) =>
getTokenStandardAndDetails(address, selectedAddress, null),
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ImportToken);

View File

@ -1,120 +0,0 @@
import React from 'react';
import { Provider } from 'react-redux';
import { action } from '@storybook/addon-actions';
import { DEFAULT_ROUTE } from '../../helpers/constants/routes';
import configureStore from '../../store/store';
import testData from '../../../.storybook/test-data';
import ImportToken from './import-token.component';
import README from './README.mdx';
const store = configureStore(testData);
const { metamask } = store.getState();
const {
networkConfigurations,
identities,
pendingTokens,
selectedAddress,
tokenList,
tokens,
} = metamask;
export default {
title: 'Pages/ImportToken',
decorators: [(story) => <Provider store={store}>{story()}</Provider>],
component: ImportToken,
parameters: {
docs: {
page: README,
},
},
argTypes: {
history: {
control: {
type: 'object',
},
},
setPendingTokens: {
action: 'setPendingTokens',
},
pendingTokens: {
control: {
type: 'object',
},
},
clearPendingTokens: {
action: 'clearPendingTokens',
},
tokens: {
control: {
type: 'object',
},
},
identities: {
control: {
type: 'object',
},
},
showSearchTab: {
control: {
type: 'boolean',
},
},
mostRecentOverviewPage: {
control: {
type: 'text',
},
},
chainId: {
control: {
type: 'text',
},
},
rpcPrefs: {
control: {
type: 'object',
},
},
tokenList: {
control: {
type: 'object',
},
},
useTokenDetection: {
control: {
type: 'boolean',
},
},
getTokenStandardAndDetails: {
action: 'getTokenStandardAndDetails',
},
selectedAddress: {
control: {
type: 'text',
},
},
},
args: {
history: {
push: action('history.push()'),
},
pendingTokens,
tokens,
identities,
showSearchTab: true,
mostRecentOverviewPage: DEFAULT_ROUTE,
chainId: networkConfigurations['test-networkConfigurationId-1'].chainId,
rpcPrefs: networkConfigurations['test-networkConfigurationId-1'].rpcPrefs,
tokenList,
useTokenDetection: false,
selectedAddress,
},
};
export const DefaultStory = (args) => {
return <ImportToken {...args} />;
};
DefaultStory.storyName = 'Default';

View File

@ -1,178 +0,0 @@
import React from 'react';
import { fireEvent } from '@testing-library/react';
import { renderWithProvider } from '../../../test/lib/render-helpers';
import configureStore from '../../store/store';
import {
setPendingTokens,
clearPendingTokens,
getTokenStandardAndDetails,
} from '../../store/actions';
import ImportToken from './import-token.container';
jest.mock('../../store/actions', () => ({
getTokenStandardAndDetails: jest
.fn()
.mockImplementation(() => Promise.resolve({ standard: 'ERC20' })),
setPendingTokens: jest
.fn()
.mockImplementation(() => ({ type: 'SET_PENDING_TOKENS' })),
clearPendingTokens: jest
.fn()
.mockImplementation(() => ({ type: 'CLEAR_PENDING_TOKENS' })),
}));
describe('Import Token', () => {
const historyStub = jest.fn();
const props = {
history: {
push: historyStub,
},
showSearchTab: true,
tokenList: {},
};
const render = () => {
const baseStore = {
metamask: {
tokens: [],
providerConfig: { chainId: '0x1' },
networkConfigurations: {},
identities: {},
selectedAddress: '0x1231231',
useTokenDetection: true,
},
history: {
mostRecentOverviewPage: '/',
},
};
const store = configureStore(baseStore);
return renderWithProvider(<ImportToken {...props} />, store);
};
describe('Import Token', () => {
it('add custom token button is disabled when no fields are populated', () => {
const { getByText } = render();
const customTokenButton = getByText('Custom token');
fireEvent.click(customTokenButton);
const submit = getByText('Add custom token');
expect(submit).toBeDisabled();
});
it('edits token address', () => {
const { getByText } = render();
const customTokenButton = getByText('Custom token');
fireEvent.click(customTokenButton);
const tokenAddress = '0x617b3f8050a0BD94b6b1da02B4384eE5B4DF13F4';
const event = { target: { value: tokenAddress } };
fireEvent.change(document.getElementById('custom-address'), event);
expect(document.getElementById('custom-address').value).toStrictEqual(
tokenAddress,
);
});
it('edits token symbol', () => {
const { getByText } = render();
const customTokenButton = getByText('Custom token');
fireEvent.click(customTokenButton);
const tokenSymbol = 'META';
const event = { target: { value: tokenSymbol } };
fireEvent.change(document.getElementById('custom-symbol'), event);
expect(document.getElementById('custom-symbol').value).toStrictEqual(
tokenSymbol,
);
});
it('edits token decimal precision', () => {
const { getByText } = render();
const customTokenButton = getByText('Custom token');
fireEvent.click(customTokenButton);
const tokenPrecision = '2';
const event = { target: { value: tokenPrecision } };
fireEvent.change(document.getElementById('custom-decimals'), event);
expect(document.getElementById('custom-decimals').value).toStrictEqual(
tokenPrecision,
);
});
it('adds custom tokens successfully', async () => {
const { getByText } = render();
const customTokenButton = getByText('Custom token');
fireEvent.click(customTokenButton);
const submit = getByText('Add custom token');
expect(submit).toBeDisabled();
const tokenAddress = '0x617b3f8050a0BD94b6b1da02B4384eE5B4DF13F4';
fireEvent.change(document.getElementById('custom-address'), {
target: { value: tokenAddress },
});
expect(submit).not.toBeDisabled();
const tokenSymbol = 'META';
fireEvent.change(document.getElementById('custom-symbol'), {
target: { value: tokenSymbol },
});
const tokenPrecision = '2';
await fireEvent.change(document.getElementById('custom-decimals'), {
target: { value: tokenPrecision },
});
expect(submit).not.toBeDisabled();
fireEvent.click(submit);
expect(setPendingTokens).toHaveBeenCalledWith({
customToken: {
address: tokenAddress,
decimals: Number(tokenPrecision),
standard: 'ERC20',
symbol: tokenSymbol,
},
selectedTokens: {},
tokenAddressList: [],
});
expect(historyStub).toHaveBeenCalledWith('/confirm-import-token');
});
it('cancels out of import token flow', () => {
const { getByRole } = render();
const closeButton = getByRole('button', { name: 'close' });
fireEvent.click(closeButton);
expect(clearPendingTokens).toHaveBeenCalled();
expect(historyStub).toHaveBeenCalledWith('/');
});
it('sets and error when a token is an NFT', async () => {
getTokenStandardAndDetails.mockImplementation(() =>
Promise.resolve({ standard: 'ERC721' }),
);
const { getByText } = render();
const customTokenButton = getByText('Custom token');
fireEvent.click(customTokenButton);
const submit = getByText('Add custom token');
expect(submit).toBeDisabled();
const tokenAddress = '0x617b3f8050a0BD94b6b1da02B4384eE5B4DF13F4';
await fireEvent.change(document.getElementById('custom-address'), {
target: { value: tokenAddress },
});
expect(submit).toBeDisabled();
// The last part of this error message won't be found by getByText because it is wrapped as a link.
const errorMessage = getByText('This token is an NFT. Add on the');
expect(errorMessage).toBeInTheDocument();
});
});
});

View File

@ -1,3 +0,0 @@
import ImportToken from './import-token.container';
export default ImportToken;

View File

@ -1,79 +0,0 @@
@import 'token-list/index';
.import-token {
$self: &;
&__custom-token-form {
padding: 8px 16px 16px;
input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
display: none;
}
input[type='number']:hover::-webkit-inner-spin-button {
-webkit-appearance: none;
display: none;
}
& #{$self}__decimal-warning {
margin-top: 5px;
}
}
&__search-token {
padding: 16px 16px 16px 16px;
}
&__token-list {
margin-top: 16px;
}
&__custom-symbol {
&__label-wrapper {
display: flex;
flex-flow: row nowrap;
}
&__label {
flex: 0 0 auto;
}
&__edit {
flex: 1 1 auto;
text-align: right;
color: var(--color-primary-default);
padding-right: 4px;
cursor: pointer;
}
}
&__link {
@include H7;
display: inline;
color: var(--color-primary-default);
padding-left: 0;
}
&__token-detection-announcement {
margin-bottom: 16px;
margin-top: 0;
}
&__close {
color: var(--color-icon-default);
background: none;
flex: 0;
align-self: flex-start;
padding-right: 0;
}
&__nft-address-error-link {
color: var(--color-primary-default);
cursor: pointer;
&:hover {
color: var(--color-primary-default);
}
}
}

View File

@ -1,27 +0,0 @@
.token-list-placeholder {
display: flex;
align-items: center;
padding-top: 36px;
flex-direction: column;
line-height: 22px;
img {
opacity: 0.5;
}
&__text {
color: var(--color-text-alternative);
width: 50%;
text-align: center;
margin-top: 8px;
opacity: 0.5;
@include screen-sm-max {
width: 60%;
}
}
&__link {
margin-top: 0.5rem;
}
}

View File

@ -1,31 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Button from '../../../../components/ui/button';
import IconTokenSearch from '../../../../components/ui/icon/icon-token-search';
import ZENDESK_URLS from '../../../../helpers/constants/zendesk-url';
export default class TokenListPlaceholder extends Component {
static contextTypes = {
t: PropTypes.func,
};
render() {
return (
<div className="token-list-placeholder">
<IconTokenSearch size={64} color="var(--color-icon-muted)" />
<div className="token-list-placeholder__text">
{this.context.t('addAcquiredTokens')}
</div>
<Button
type="link"
className="token-list-placeholder__link"
href={ZENDESK_URLS.ADD_CUSTOM_TOKENS}
target="_blank"
rel="noopener noreferrer"
>
{this.context.t('learnMoreUpperCase')}
</Button>
</div>
);
}
}

View File

@ -1,6 +1,5 @@
/** Please import your files in alphabetical order **/
@import 'asset/asset';
@import 'confirm-import-token/index';
@import 'confirm-add-suggested-token/index';
@import 'confirm-add-suggested-nft/index';
@import 'confirm-approve/index';
@ -15,7 +14,6 @@
@import 'desktop-pairing/index';
@import 'error/index';
@import 'home/index';
@import 'import-token/index';
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
@import "institutional/connect-custody/index";
@import "institutional/institutional-entity-done-page/index";

View File

@ -7,6 +7,7 @@ import IdleTimer from 'react-idle-timer';
///: BEGIN:ONLY_INCLUDE_IN(desktop)
import browserAPI from 'webextension-polyfill';
///: END:ONLY_INCLUDE_IN
import SendTransactionScreen from '../send';
import Swaps from '../swaps';
import ConfirmTransaction from '../confirm-transaction';
@ -18,8 +19,6 @@ import Lock from '../lock';
import PermissionsConnect from '../permissions-connect';
import RestoreVaultPage from '../keychains/restore-vault';
import RevealSeedConfirmation from '../keychains/reveal-seed';
import ImportTokenPage from '../import-token';
import ConfirmImportTokenPage from '../confirm-import-token';
import ConfirmAddSuggestedTokenPage from '../confirm-add-suggested-token';
import CreateAccountPage from '../create-account/create-account.component';
import ConfirmAddSuggestedNftPage from '../confirm-add-suggested-nft';
@ -33,6 +32,7 @@ import {
NetworkListMenu,
AccountDetails,
ImportNftsModal,
ImportTokensModal,
} from '../../components/multichain';
import UnlockPage from '../unlock-page';
import Alerts from '../../components/app/alerts';
@ -59,7 +59,6 @@ import CustodyPage from '../institutional/custody';
///: END:ONLY_INCLUDE_IN
import {
IMPORT_TOKEN_ROUTE,
ASSET_ROUTE,
CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE,
CONFIRM_ADD_SUGGESTED_NFT_ROUTE,
@ -76,7 +75,6 @@ import {
UNLOCK_ROUTE,
BUILD_QUOTE_ROUTE,
CONFIRMATION_V_NEXT_ROUTE,
CONFIRM_IMPORT_TOKEN_ROUTE,
ONBOARDING_ROUTE,
ONBOARDING_UNLOCK_ROUTE,
TOKEN_DETAILS,
@ -162,6 +160,8 @@ export default class Routes extends Component {
hideImportNftsModal: PropTypes.func.isRequired,
isIpfsModalOpen: PropTypes.bool.isRequired,
hideIpfsModal: PropTypes.func.isRequired,
isImportTokensModalOpen: PropTypes.bool.isRequired,
hideImportTokensModal: PropTypes.func.isRequired,
};
static contextTypes = {
@ -279,16 +279,6 @@ export default class Routes extends Component {
exact
/>
<Authenticated path={SWAPS_ROUTE} component={Swaps} />
<Authenticated
path={IMPORT_TOKEN_ROUTE}
component={ImportTokenPage}
exact
/>
<Authenticated
path={CONFIRM_IMPORT_TOKEN_ROUTE}
component={ConfirmImportTokenPage}
exact
/>
<Authenticated
path={CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE}
component={ConfirmAddSuggestedTokenPage}
@ -515,12 +505,15 @@ export default class Routes extends Component {
isNetworkMenuOpen,
toggleNetworkMenu,
accountDetailsAddress,
isImportTokensModalOpen,
location,
isImportNftsModalOpen,
hideImportNftsModal,
isIpfsModalOpen,
hideIpfsModal,
hideImportTokensModal,
} = this.props;
const loadMessage =
loadingMessage || isNetworkLoading
? this.getConnectingLabel(loadingMessage)
@ -584,6 +577,9 @@ export default class Routes extends Component {
{isIpfsModalOpen ? (
<ToggleIpfsModal onClose={() => hideIpfsModal()} />
) : null}
{isImportTokensModalOpen ? (
<ImportTokensModal onClose={() => hideImportTokensModal()} />
) : null}
<Box className="main-container-wrapper">
{isLoading ? <Loading loadingMessage={loadMessage} /> : null}
{!isLoading && isNetworkLoading ? <LoadingNetwork /> : null}

View File

@ -14,6 +14,7 @@ import {
isCurrentProviderCustom,
} from '../../selectors';
import {
hideImportTokensModal,
lockMetamask,
hideImportNftsModal,
hideIpfsModal,
@ -64,6 +65,7 @@ function mapStateToProps(state) {
completedOnboarding,
isAccountMenuOpen: state.metamask.isAccountMenuOpen,
isNetworkMenuOpen: state.metamask.isNetworkMenuOpen,
isImportTokensModalOpen: state.appState.importTokensModalOpen,
accountDetailsAddress: state.appState.accountDetailsAddress,
isImportNftsModalOpen: state.appState.importNftsModalOpen,
isIpfsModalOpen: state.appState.showIpfsModalOpen,
@ -83,6 +85,7 @@ function mapDispatchToProps(dispatch) {
toggleNetworkMenu: () => dispatch(toggleNetworkMenu()),
hideImportNftsModal: () => dispatch(hideImportNftsModal()),
hideIpfsModal: () => dispatch(hideIpfsModal()),
hideImportTokensModal: () => dispatch(hideImportTokensModal()),
};
}

View File

@ -627,6 +627,7 @@ exports[`Security Tab should match snapshot 1`] = `
<div
class="mm-box settings-page__content-row mm-box--display-flex mm-box--flex-direction-row mm-box--justify-content-space-between"
data-testid="advanced-setting-gas-fee-estimation"
id="advanced-settings-autodetect-tokens"
>
<div
class="settings-page__content-item"

View File

@ -531,6 +531,7 @@ export default class SecurityTab extends PureComponent {
display={Display.Flex}
flexDirection={FlexDirection.Row}
justifyContent={JustifyContent.spaceBetween}
id="advanced-settings-autodetect-tokens"
>
<div className="settings-page__content-item">
<span>{t('autoDetectTokens')}</span>

View File

@ -13,6 +13,8 @@ export const IMPORT_NFTS_MODAL_OPEN = 'UI_IMPORT_NFTS_MODAL_OPEN';
export const IMPORT_NFTS_MODAL_CLOSE = 'UI_IMPORT_NFTS_MODAL_CLOSE';
export const SHOW_IPFS_MODAL_OPEN = 'UI_IPFS_MODAL_OPEN';
export const SHOW_IPFS_MODAL_CLOSE = 'UI_IPFS_MODAL_CLOSE';
export const IMPORT_TOKENS_POPOVER_OPEN = 'UI_IMPORT_TOKENS_POPOVER_OPEN';
export const IMPORT_TOKENS_POPOVER_CLOSE = 'UI_IMPORT_TOKENS_POPOVER_CLOSE';
// remote state
export const UPDATE_METAMASK_STATE = 'UPDATE_METAMASK_STATE';
export const SELECTED_ADDRESS_CHANGED = 'SELECTED_ADDRESS_CHANGED';

Some files were not shown because too many files have changed in this diff Show More