mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-22 01:47:00 +01:00
* Move Import Tokens to Modal * Better dimensions for long token name * Add padding above tabs
This commit is contained in:
parent
d6eecf8584
commit
ee4bf2d264
3
app/_locales/am/messages.json
generated
3
app/_locales/am/messages.json
generated
@ -609,9 +609,6 @@
|
||||
"searchResults": {
|
||||
"message": "ውጤቶችን ፈልግ"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "ተለዋጭ ስሞችን ፈልግ"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "ደህንነት እና ግላዊነት"
|
||||
},
|
||||
|
3
app/_locales/ar/messages.json
generated
3
app/_locales/ar/messages.json
generated
@ -621,9 +621,6 @@
|
||||
"searchResults": {
|
||||
"message": "نتائج البحث"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "البحث عن العملات الرمزية"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "الأمن والخصوصية"
|
||||
},
|
||||
|
3
app/_locales/bg/messages.json
generated
3
app/_locales/bg/messages.json
generated
@ -620,9 +620,6 @@
|
||||
"searchResults": {
|
||||
"message": "Резултати от търсенето"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Търсене на маркери"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Сигурност и поверителност"
|
||||
},
|
||||
|
3
app/_locales/bn/messages.json
generated
3
app/_locales/bn/messages.json
generated
@ -618,9 +618,6 @@
|
||||
"searchResults": {
|
||||
"message": "অনুসন্ধানের ফলাফলগুলি"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "টোকেনগুলি অনুসন্ধান করুন"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "নিরাপত্তা এবং গোপনীয়তা"
|
||||
},
|
||||
|
3
app/_locales/ca/messages.json
generated
3
app/_locales/ca/messages.json
generated
@ -605,9 +605,6 @@
|
||||
"searchResults": {
|
||||
"message": "Resultats de Cerca"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Tokens per cercar"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Seguretat i privacitat"
|
||||
},
|
||||
|
3
app/_locales/cs/messages.json
generated
3
app/_locales/cs/messages.json
generated
@ -289,9 +289,6 @@
|
||||
"search": {
|
||||
"message": "Hledat"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Hledat tokeny"
|
||||
},
|
||||
"seedPhraseReq": {
|
||||
"message": "klíčové fráze mají 12 slov"
|
||||
},
|
||||
|
3
app/_locales/da/messages.json
generated
3
app/_locales/da/messages.json
generated
@ -605,9 +605,6 @@
|
||||
"searchResults": {
|
||||
"message": "Søg Resultater"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Søg efter tokens"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Sikkerhed & Privatliv"
|
||||
},
|
||||
|
6
app/_locales/de/messages.json
generated
6
app/_locales/de/messages.json
generated
@ -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"
|
||||
},
|
||||
|
6
app/_locales/el/messages.json
generated
6
app/_locales/el/messages.json
generated
@ -186,9 +186,6 @@
|
||||
"addCustomNetwork": {
|
||||
"message": "Προσθήκη προσαρμοσμένου δικτύου"
|
||||
},
|
||||
"addCustomToken": {
|
||||
"message": "Προσθήκη Προσαρμοσμένου Token"
|
||||
},
|
||||
"addEthereumChainConfirmationDescription": {
|
||||
"message": "Αυτό θα επιτρέψει σε αυτό το δίκτυο να χρησιμοποιηθεί στο MetaMask."
|
||||
},
|
||||
@ -2977,9 +2974,6 @@
|
||||
"searchAccounts": {
|
||||
"message": "Αναζήτηση Λογαριασμών"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Αναζήτηση Tokens"
|
||||
},
|
||||
"secretRecoveryPhrase": {
|
||||
"message": "Μυστική Φράση Ανάκτησης"
|
||||
},
|
||||
|
6
app/_locales/en/messages.json
generated
6
app/_locales/en/messages.json
generated
@ -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"
|
||||
},
|
||||
|
6
app/_locales/es/messages.json
generated
6
app/_locales/es/messages.json
generated
@ -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"
|
||||
},
|
||||
|
6
app/_locales/es_419/messages.json
generated
6
app/_locales/es_419/messages.json
generated
@ -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"
|
||||
},
|
||||
|
3
app/_locales/et/messages.json
generated
3
app/_locales/et/messages.json
generated
@ -614,9 +614,6 @@
|
||||
"searchResults": {
|
||||
"message": "Otsingutulemused"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Lubade otsimine"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Turvalisus ja privaatsus"
|
||||
},
|
||||
|
3
app/_locales/fa/messages.json
generated
3
app/_locales/fa/messages.json
generated
@ -624,9 +624,6 @@
|
||||
"searchResults": {
|
||||
"message": "نتایج جستجو"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "رمزیاب های جستجو"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "امنیت و حریم خصوصی"
|
||||
},
|
||||
|
3
app/_locales/fi/messages.json
generated
3
app/_locales/fi/messages.json
generated
@ -621,9 +621,6 @@
|
||||
"searchResults": {
|
||||
"message": "Hakutulokset"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Hae tietueita"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Turva & yksityisyys"
|
||||
},
|
||||
|
3
app/_locales/fil/messages.json
generated
3
app/_locales/fil/messages.json
generated
@ -548,9 +548,6 @@
|
||||
"searchResults": {
|
||||
"message": "Mga Resulta ng Paghahanap"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Maghanap ng Mga Token"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Seguridad at Privacy"
|
||||
},
|
||||
|
6
app/_locales/fr/messages.json
generated
6
app/_locales/fr/messages.json
generated
@ -186,9 +186,6 @@
|
||||
"addCustomNetwork": {
|
||||
"message": "Ajouter un réseau personnalisé"
|
||||
},
|
||||
"addCustomToken": {
|
||||
"message": "Ajouter un jeton personnalisé"
|
||||
},
|
||||
"addEthereumChainConfirmationDescription": {
|
||||
"message": "Cela permettra d’utiliser 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"
|
||||
},
|
||||
|
3
app/_locales/he/messages.json
generated
3
app/_locales/he/messages.json
generated
@ -621,9 +621,6 @@
|
||||
"searchResults": {
|
||||
"message": "תוצאות חיפוש"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "חיפוש טוקנים"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "אבטחה ופרטיות"
|
||||
},
|
||||
|
6
app/_locales/hi/messages.json
generated
6
app/_locales/hi/messages.json
generated
@ -186,9 +186,6 @@
|
||||
"addCustomNetwork": {
|
||||
"message": "कस्टम नेटवर्क जोड़ें"
|
||||
},
|
||||
"addCustomToken": {
|
||||
"message": "कस्टम टोकन जोड़ें"
|
||||
},
|
||||
"addEthereumChainConfirmationDescription": {
|
||||
"message": "इससे इस नेटवर्क को MetaMask के अंदर उपयोग करने की अनुमति मिलेगी।"
|
||||
},
|
||||
@ -2980,9 +2977,6 @@
|
||||
"searchResults": {
|
||||
"message": "खोज परिणाम"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "टोकन खोजें"
|
||||
},
|
||||
"secretRecoveryPhrase": {
|
||||
"message": "सीक्रेट रिकवरी फ्रेज"
|
||||
},
|
||||
|
3
app/_locales/hr/messages.json
generated
3
app/_locales/hr/messages.json
generated
@ -617,9 +617,6 @@
|
||||
"searchResults": {
|
||||
"message": "Rezultati pretraživanja"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Pretraži tokene"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Sigurnost i privatnost"
|
||||
},
|
||||
|
3
app/_locales/ht/messages.json
generated
3
app/_locales/ht/messages.json
generated
@ -452,9 +452,6 @@
|
||||
"searchResults": {
|
||||
"message": "Rezilta rechèch"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Rechèch Tokens"
|
||||
},
|
||||
"seedPhraseReq": {
|
||||
"message": "Seed fraz yo se 12 long mo"
|
||||
},
|
||||
|
3
app/_locales/hu/messages.json
generated
3
app/_locales/hu/messages.json
generated
@ -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"
|
||||
},
|
||||
|
6
app/_locales/id/messages.json
generated
6
app/_locales/id/messages.json
generated
@ -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"
|
||||
},
|
||||
|
6
app/_locales/it/messages.json
generated
6
app/_locales/it/messages.json
generated
@ -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"
|
||||
},
|
||||
|
6
app/_locales/ja/messages.json
generated
6
app/_locales/ja/messages.json
generated
@ -186,9 +186,6 @@
|
||||
"addCustomNetwork": {
|
||||
"message": "カスタムネットワークを追加"
|
||||
},
|
||||
"addCustomToken": {
|
||||
"message": "カスタムトークンを追加"
|
||||
},
|
||||
"addEthereumChainConfirmationDescription": {
|
||||
"message": "これにより、このネットワークはMetaMask内で使用できるようになります。"
|
||||
},
|
||||
@ -2980,9 +2977,6 @@
|
||||
"searchResults": {
|
||||
"message": "検索結果"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "トークンを検索"
|
||||
},
|
||||
"secretRecoveryPhrase": {
|
||||
"message": "シークレットリカバリーフレーズ"
|
||||
},
|
||||
|
3
app/_locales/kn/messages.json
generated
3
app/_locales/kn/messages.json
generated
@ -624,9 +624,6 @@
|
||||
"searchResults": {
|
||||
"message": "ಹುಡುಕಾಟ ಫಲಿತಾಂಶಗಳು"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "ಟೋಕನ್ಗಳನ್ನು ಹುಡುಕಿ"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "ಭದ್ರತೆ ಮತ್ತು ಗೌಪ್ಯತೆ"
|
||||
},
|
||||
|
6
app/_locales/ko/messages.json
generated
6
app/_locales/ko/messages.json
generated
@ -186,9 +186,6 @@
|
||||
"addCustomNetwork": {
|
||||
"message": "커스텀 네트워크 추가"
|
||||
},
|
||||
"addCustomToken": {
|
||||
"message": "커스텀 토큰 추가"
|
||||
},
|
||||
"addEthereumChainConfirmationDescription": {
|
||||
"message": "이렇게 하면 MetaMask 내에서 이 네트워크를 사용할 수 있습니다."
|
||||
},
|
||||
@ -2980,9 +2977,6 @@
|
||||
"searchResults": {
|
||||
"message": "검색 결과"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "토큰 검색"
|
||||
},
|
||||
"secretRecoveryPhrase": {
|
||||
"message": "비밀 복구 구문"
|
||||
},
|
||||
|
3
app/_locales/lt/messages.json
generated
3
app/_locales/lt/messages.json
generated
@ -624,9 +624,6 @@
|
||||
"searchResults": {
|
||||
"message": "Paieškos rezultatai"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Ieškoti žetonų"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Sauga ir privatumas"
|
||||
},
|
||||
|
3
app/_locales/lv/messages.json
generated
3
app/_locales/lv/messages.json
generated
@ -620,9 +620,6 @@
|
||||
"searchResults": {
|
||||
"message": "Meklēšanas rezultāti"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Meklēt marķierus"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Drošība un konfidencialitāte"
|
||||
},
|
||||
|
3
app/_locales/ms/messages.json
generated
3
app/_locales/ms/messages.json
generated
@ -604,9 +604,6 @@
|
||||
"searchResults": {
|
||||
"message": "Hasil Carian"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Cari Token"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Keselamatan & Privasi"
|
||||
},
|
||||
|
3
app/_locales/no/messages.json
generated
3
app/_locales/no/messages.json
generated
@ -608,9 +608,6 @@
|
||||
"searchResults": {
|
||||
"message": "Søkeresultater"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Søk i sjetonger"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Sikkerhet og personvern"
|
||||
},
|
||||
|
3
app/_locales/ph/messages.json
generated
3
app/_locales/ph/messages.json
generated
@ -1211,9 +1211,6 @@
|
||||
"searchResults": {
|
||||
"message": "Mga Resulta ng Paghahanap"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Maghanap ng Mga Token"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Seguridad at Privacy"
|
||||
},
|
||||
|
3
app/_locales/pl/messages.json
generated
3
app/_locales/pl/messages.json
generated
@ -618,9 +618,6 @@
|
||||
"searchResults": {
|
||||
"message": "Wyniki wyszukiwania"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Szukaj tokenów"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Bezpieczeństwo i prywatność"
|
||||
},
|
||||
|
6
app/_locales/pt/messages.json
generated
6
app/_locales/pt/messages.json
generated
@ -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"
|
||||
},
|
||||
|
6
app/_locales/pt_BR/messages.json
generated
6
app/_locales/pt_BR/messages.json
generated
@ -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"
|
||||
},
|
||||
|
3
app/_locales/ro/messages.json
generated
3
app/_locales/ro/messages.json
generated
@ -611,9 +611,6 @@
|
||||
"searchResults": {
|
||||
"message": "Rezultate căutare"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Căutați token-uri"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Securitate și confidențialitate"
|
||||
},
|
||||
|
6
app/_locales/ru/messages.json
generated
6
app/_locales/ru/messages.json
generated
@ -186,9 +186,6 @@
|
||||
"addCustomNetwork": {
|
||||
"message": "Добавить пользовательскую сеть"
|
||||
},
|
||||
"addCustomToken": {
|
||||
"message": "Добавить пользовательский токен"
|
||||
},
|
||||
"addEthereumChainConfirmationDescription": {
|
||||
"message": "Это позволит использовать эту сеть в MetaMask."
|
||||
},
|
||||
@ -2980,9 +2977,6 @@
|
||||
"searchResults": {
|
||||
"message": "Результаты поиска"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Поиск токенов"
|
||||
},
|
||||
"secretRecoveryPhrase": {
|
||||
"message": "Секретная фраза для восстановления"
|
||||
},
|
||||
|
3
app/_locales/sk/messages.json
generated
3
app/_locales/sk/messages.json
generated
@ -596,9 +596,6 @@
|
||||
"searchResults": {
|
||||
"message": "Výsledky vyhľadávania"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Hledat tokeny"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Bezpečnosť a súkromie"
|
||||
},
|
||||
|
3
app/_locales/sl/messages.json
generated
3
app/_locales/sl/messages.json
generated
@ -612,9 +612,6 @@
|
||||
"searchResults": {
|
||||
"message": "Rezultati iskanja"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Iskanje žetonov"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Varnost in zasebnost"
|
||||
},
|
||||
|
3
app/_locales/sr/messages.json
generated
3
app/_locales/sr/messages.json
generated
@ -615,9 +615,6 @@
|
||||
"searchResults": {
|
||||
"message": "Rezultati pretrage"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Pretražite tokene"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Bezbednost i privatnost"
|
||||
},
|
||||
|
3
app/_locales/sv/messages.json
generated
3
app/_locales/sv/messages.json
generated
@ -608,9 +608,6 @@
|
||||
"searchResults": {
|
||||
"message": "Sökresultat"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Sök tokens"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Säkerhet och integritet"
|
||||
},
|
||||
|
3
app/_locales/sw/messages.json
generated
3
app/_locales/sw/messages.json
generated
@ -602,9 +602,6 @@
|
||||
"searchResults": {
|
||||
"message": "Matokeo ya Utafutaji"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Tafuta Vianzio"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Ulinzi na Faragha"
|
||||
},
|
||||
|
3
app/_locales/ta/messages.json
generated
3
app/_locales/ta/messages.json
generated
@ -359,9 +359,6 @@
|
||||
"search": {
|
||||
"message": "தேடல்"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "தேடல் டோக்கன்ஸ்"
|
||||
},
|
||||
"seedPhraseReq": {
|
||||
"message": "விதை வாக்கியங்கள் 12 வார்த்தைகள் நீண்டவை"
|
||||
},
|
||||
|
6
app/_locales/tl/messages.json
generated
6
app/_locales/tl/messages.json
generated
@ -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"
|
||||
},
|
||||
|
6
app/_locales/tr/messages.json
generated
6
app/_locales/tr/messages.json
generated
@ -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"
|
||||
},
|
||||
|
3
app/_locales/uk/messages.json
generated
3
app/_locales/uk/messages.json
generated
@ -624,9 +624,6 @@
|
||||
"searchResults": {
|
||||
"message": "Результати пошуку"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "Шукати токени"
|
||||
},
|
||||
"securityAndPrivacy": {
|
||||
"message": "Безпека й конфіденційність"
|
||||
},
|
||||
|
6
app/_locales/vi/messages.json
generated
6
app/_locales/vi/messages.json
generated
@ -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"
|
||||
},
|
||||
|
6
app/_locales/zh_CN/messages.json
generated
6
app/_locales/zh_CN/messages.json
generated
@ -186,9 +186,6 @@
|
||||
"addCustomNetwork": {
|
||||
"message": "添加自定义网络"
|
||||
},
|
||||
"addCustomToken": {
|
||||
"message": "添加自定义代币"
|
||||
},
|
||||
"addEthereumChainConfirmationDescription": {
|
||||
"message": "这将允许在 MetaMask 中使用此网络。"
|
||||
},
|
||||
@ -2980,9 +2977,6 @@
|
||||
"searchResults": {
|
||||
"message": "搜索结果"
|
||||
},
|
||||
"searchTokens": {
|
||||
"message": "搜索代币"
|
||||
},
|
||||
"secretRecoveryPhrase": {
|
||||
"message": "助记词"
|
||||
},
|
||||
|
6
app/_locales/zh_TW/messages.json
generated
6
app/_locales/zh_TW/messages.json
generated
@ -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"
|
||||
},
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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');
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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' });
|
||||
|
||||
|
@ -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';
|
||||
|
1
ui/components/app/import-token/index.scss
Normal file
1
ui/components/app/import-token/index.scss
Normal file
@ -0,0 +1 @@
|
||||
@import 'token-list/index';
|
@ -1,5 +1,3 @@
|
||||
@import 'token-list-placeholder/index';
|
||||
|
||||
.token-list {
|
||||
&__title {
|
||||
@include H7;
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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 = () => {
|
@ -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 {
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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: {
|
@ -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,
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
};
|
@ -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';
|
@ -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,
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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';
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
1
ui/components/multichain/import-tokens-modal/index.js
Normal file
1
ui/components/multichain/import-tokens-modal/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { ImportTokensModal } from './import-tokens-modal';
|
@ -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';
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
|
@ -90,4 +90,8 @@
|
||||
transform: rotate(45deg);
|
||||
box-shadow: var(--shadow-size-lg) var(--color-shadow-default);
|
||||
}
|
||||
|
||||
&-container .page-container {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
@ -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';
|
@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
@ -1,3 +0,0 @@
|
||||
import ConfirmImportToken from './confirm-import-token';
|
||||
|
||||
export default ConfirmImportToken;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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} />
|
@ -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;
|
@ -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);
|
@ -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';
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -1,3 +0,0 @@
|
||||
import ImportToken from './import-token.container';
|
||||
|
||||
export default ImportToken;
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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";
|
||||
|
@ -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}
|
||||
|
@ -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()),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user