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

Refactor send page state management (#10965)

This commit is contained in:
Brad Decker 2021-06-23 16:35:25 -05:00 committed by ryanml
parent 85f17831a2
commit e17325c38a
139 changed files with 5040 additions and 5710 deletions

View File

@ -751,9 +751,6 @@
"recents": {
"message": "የቅርብ ጊዜያት"
},
"recipientAddress": {
"message": "የተቀባይ አድራሻ"
},
"recipientAddressPlaceholder": {
"message": "ፍለጋ፣ ለሕዝብ ክፍት የሆነ አድራሻ (0x), ወይም ENS"
},

View File

@ -747,9 +747,6 @@
"recents": {
"message": "الحديث"
},
"recipientAddress": {
"message": "عنوان المستلم"
},
"recipientAddressPlaceholder": {
"message": "البحث، العنوان العام (0x)، أو ENS"
},

View File

@ -750,9 +750,6 @@
"recents": {
"message": "Скорошни"
},
"recipientAddress": {
"message": "Адрес на получателя"
},
"recipientAddressPlaceholder": {
"message": "Търсене, публичен адрес (0x) или ENS"
},

View File

@ -754,9 +754,6 @@
"recents": {
"message": "সাম্প্রতিকগুলি"
},
"recipientAddress": {
"message": "প্রাপকের ঠিকানা"
},
"recipientAddressPlaceholder": {
"message": "অনুসন্ধান, সার্বজনীন ঠিকানা (0x), বা ENS"
},

View File

@ -732,9 +732,6 @@
"readdToken": {
"message": "Pots tornar a afegir aquesta fitxa en el futur anant a \"Afegir fitxa\" al menu d'opcions dels teus comptes."
},
"recipientAddress": {
"message": "Adreça del destinatari"
},
"recipientAddressPlaceholder": {
"message": "Cerca, adreça pública (0x), o ENS"
},

View File

@ -308,9 +308,6 @@
"readdToken": {
"message": "Tento token můžete v budoucnu přidat zpět s „Přidat token“ v nastavení účtu."
},
"recipientAddress": {
"message": "Adresa příjemce"
},
"reject": {
"message": "Odmítnout"
},

View File

@ -735,9 +735,6 @@
"recents": {
"message": "Seneste"
},
"recipientAddress": {
"message": "Modtagerens adresse"
},
"recipientAddressPlaceholder": {
"message": "Søg, offentlig adresse (0x) eller ENS"
},

View File

@ -723,9 +723,6 @@
"recents": {
"message": "Letzte"
},
"recipientAddress": {
"message": "Empfängeradresse"
},
"recipientAddressPlaceholder": {
"message": "Suchen, öffentliche Adresse (0x) oder ENS"
},

View File

@ -751,9 +751,6 @@
"recents": {
"message": "Πρόσφατα"
},
"recipientAddress": {
"message": "Διεύθυνση Παραλήπτη"
},
"recipientAddressPlaceholder": {
"message": "Αναζήτηση, δημόσια διεύθυνση (0x) ή ENS"
},

View File

@ -650,12 +650,21 @@
"message": "The endpoint returned a different chain ID: $1",
"description": "$1 is the return value of eth_chainId from an RPC endpoint"
},
"ensIllegalCharacter": {
"message": "Illegal Character for ENS."
},
"ensNotFoundOnCurrentNetwork": {
"message": "ENS name not found on the current network. Try switching to Ethereum Mainnet."
},
"ensNotSupportedOnNetwork": {
"message": "Network does not support ENS"
},
"ensRegistrationError": {
"message": "Error in ENS name registration"
},
"ensUnknownError": {
"message": "ENS Lookup failed."
},
"enterAnAlias": {
"message": "Enter an alias"
},
@ -1451,9 +1460,6 @@
"recents": {
"message": "Recents"
},
"recipientAddress": {
"message": "Recipient Address"
},
"recipientAddressPlaceholder": {
"message": "Search, public address (0x), or ENS"
},

View File

@ -1411,9 +1411,6 @@
"recents": {
"message": "Recientes"
},
"recipientAddress": {
"message": "Dirección del destinatario"
},
"recipientAddressPlaceholder": {
"message": "Búsqueda, dirección pública (0x) o ENS"
},

View File

@ -1419,9 +1419,6 @@
"recents": {
"message": "Recientes"
},
"recipientAddress": {
"message": "Dirección del destinatario"
},
"recipientAddressPlaceholder": {
"message": "Búsqueda, dirección pública (0x) o ENS"
},

View File

@ -744,9 +744,6 @@
"recents": {
"message": "Hiljutised"
},
"recipientAddress": {
"message": "Saaja aadress"
},
"recipientAddressPlaceholder": {
"message": "Otsing, avalik aadress (0x) või ENS"
},

View File

@ -754,9 +754,6 @@
"recents": {
"message": "واپسین"
},
"recipientAddress": {
"message": "آدرس دریافت کننده"
},
"recipientAddressPlaceholder": {
"message": "جستجو، آدرس عمومی (0x)، یا ENS"
},

View File

@ -751,9 +751,6 @@
"recents": {
"message": "Viimeaikaiset"
},
"recipientAddress": {
"message": "Vastaanottajan osoite"
},
"recipientAddressPlaceholder": {
"message": "Haku, julkinen osoite (0x) tai ENS"
},

View File

@ -678,9 +678,6 @@
"recents": {
"message": "Kamakailan"
},
"recipientAddress": {
"message": "Address ng Recipient"
},
"recipientAddressPlaceholder": {
"message": "Maghanap, pampublikong address (0x), o ENS"
},

View File

@ -736,9 +736,6 @@
"recents": {
"message": "Récents"
},
"recipientAddress": {
"message": "Adresse du destinataire"
},
"recipientAddressPlaceholder": {
"message": "Recherche, adresse publique (0x) ou ENS"
},

View File

@ -751,9 +751,6 @@
"recents": {
"message": "אחרונים"
},
"recipientAddress": {
"message": "כתובת הנמען"
},
"recipientAddressPlaceholder": {
"message": "חיפוש, כתובת ציבורית (0x), או ENS"
},

View File

@ -1411,9 +1411,6 @@
"recents": {
"message": "हाल ही के"
},
"recipientAddress": {
"message": "प्राप्तकर्ता का पता"
},
"recipientAddressPlaceholder": {
"message": "खोज, सार्वजनिक पता (0x) या ENS"
},

View File

@ -285,9 +285,6 @@
"readdToken": {
"message": "आप अपने खाता विकल्प मेनू में .टोकन जोड़ें. पर जाकर भविष्य में इस टोकन को वापस जोड़ सकते हैं।"
},
"recipientAddress": {
"message": "प्राप्तकर्ता पता"
},
"reject": {
"message": "अस्वीकार"
},

View File

@ -747,9 +747,6 @@
"recents": {
"message": "Nedavno"
},
"recipientAddress": {
"message": "Adresa primatelja"
},
"recipientAddressPlaceholder": {
"message": "Pretraži, javne adrese (0x) ili ENS"
},

View File

@ -450,9 +450,6 @@
"readdToken": {
"message": "Ou ka ajoute token sa aprè sa ankò ou prale nan \"Ajoute token\" nan opsyon meni kont ou an."
},
"recipientAddress": {
"message": "Adrès pou resevwa"
},
"reject": {
"message": "Rejte"
},

View File

@ -747,9 +747,6 @@
"recents": {
"message": "Legutóbbiak"
},
"recipientAddress": {
"message": "Címzett címe"
},
"recipientAddressPlaceholder": {
"message": "Keresés, nyilvános cím (0x) vagy ENS"
},

View File

@ -1411,9 +1411,6 @@
"recents": {
"message": "Terkini"
},
"recipientAddress": {
"message": "Alamat Penerima"
},
"recipientAddressPlaceholder": {
"message": "Cari, alamat publik (0x), atau ENS"
},

View File

@ -1201,9 +1201,6 @@
"recents": {
"message": "Recenti"
},
"recipientAddress": {
"message": "Indirizzo Destinatario"
},
"recipientAddressPlaceholder": {
"message": "Ricerca, indirizzo pubblico (0x) o ENS"
},

View File

@ -1411,9 +1411,6 @@
"recents": {
"message": "最近"
},
"recipientAddress": {
"message": "受信者のアドレス"
},
"recipientAddressPlaceholder": {
"message": "検索、パブリック アドレス (0x)、または ENS"
},

View File

@ -754,9 +754,6 @@
"recents": {
"message": "ಇತ್ತೀಚಿನವುಗಳು"
},
"recipientAddress": {
"message": "ಸ್ವೀಕರಿಸುವವರ ವಿಳಾಸ"
},
"recipientAddressPlaceholder": {
"message": "ಸಾರ್ವಜನಿಕ ವಿಳಾಸ (0x) ಅಥವಾ ENS ಹುಡುಕಿ"
},

View File

@ -1415,9 +1415,6 @@
"recents": {
"message": "최근"
},
"recipientAddress": {
"message": "수신인 주소"
},
"recipientAddressPlaceholder": {
"message": "검색, 공개 주소(0x) 또는 ENS"
},

View File

@ -754,9 +754,6 @@
"recents": {
"message": "Naujausi"
},
"recipientAddress": {
"message": "Gavėjo adresas"
},
"recipientAddressPlaceholder": {
"message": "Ieška, viešieji adresai (0x) arba ENS"
},

View File

@ -750,9 +750,6 @@
"recents": {
"message": "Nesenie"
},
"recipientAddress": {
"message": "Saņēmēja adrese"
},
"recipientAddressPlaceholder": {
"message": "Meklēšana, publiskā adrese (0x) vai ENS"
},

View File

@ -731,9 +731,6 @@
"recents": {
"message": "Baru-baru ini"
},
"recipientAddress": {
"message": "Alamat Penerima"
},
"recipientAddressPlaceholder": {
"message": "Cari, alamat awam (0x), atau ENS"
},

View File

@ -272,9 +272,6 @@
"readdToken": {
"message": "U kunt dit token in de toekomst weer toevoegen door naar \"Token toevoegen\" te gaan in het menu met accountopties."
},
"recipientAddress": {
"message": "Geadresseerde adres"
},
"reject": {
"message": "Afwijzen"
},

View File

@ -741,9 +741,6 @@
"recents": {
"message": "Nylige"
},
"recipientAddress": {
"message": "Mottakeradresse"
},
"recipientAddressPlaceholder": {
"message": "Søk, offentlig adresse (0x) eller ENS"
},

View File

@ -1419,9 +1419,6 @@
"recents": {
"message": "Mga Kamakailan"
},
"recipientAddress": {
"message": "Address ng Tatanggap"
},
"recipientAddressPlaceholder": {
"message": "Maghanap, pampublikong address (0x), o ENS"
},

View File

@ -748,9 +748,6 @@
"recents": {
"message": "Ostatnie"
},
"recipientAddress": {
"message": "Adres odbiorcy"
},
"recipientAddressPlaceholder": {
"message": "Szukaj, adres publiczny (0x) lub ENS"
},

View File

@ -282,9 +282,6 @@
"readdToken": {
"message": "Pode adicionar este token de novo clicando na opção “Adicionar token” no menu de opções da sua conta."
},
"recipientAddress": {
"message": "Endereço do Destinatário"
},
"reject": {
"message": "Rejeitar"
},

View File

@ -1405,9 +1405,6 @@
"recents": {
"message": "Recentes"
},
"recipientAddress": {
"message": "Endereço do destinatário"
},
"recipientAddressPlaceholder": {
"message": "Busca, endereço público (0x) ou ENS"
},

View File

@ -741,9 +741,6 @@
"recents": {
"message": "Recente"
},
"recipientAddress": {
"message": "Adresă destinatar"
},
"recipientAddressPlaceholder": {
"message": "Căutare, adresa publică (0x) sau ENS"
},

View File

@ -1411,9 +1411,6 @@
"recents": {
"message": "Недавние"
},
"recipientAddress": {
"message": "Адрес получателя"
},
"recipientAddressPlaceholder": {
"message": "Поиск, публичный адрес (0x) или ENS"
},

View File

@ -723,9 +723,6 @@
"recents": {
"message": "Posledné"
},
"recipientAddress": {
"message": "Adresa příjemce"
},
"recipientAddressPlaceholder": {
"message": "Vyhľadávať verejnú adresu (0x) alebo ENS"
},

View File

@ -742,9 +742,6 @@
"recents": {
"message": "Nedavno"
},
"recipientAddress": {
"message": "Prejemnikov naslov"
},
"recipientAddressPlaceholder": {
"message": "Iskanje, javni naslov (0x) ali ENS"
},

View File

@ -745,9 +745,6 @@
"recents": {
"message": "Skorašnje"
},
"recipientAddress": {
"message": "Adresa primaoca"
},
"recipientAddressPlaceholder": {
"message": "Pretraga, javna adresa (0x) ili ENS"
},

View File

@ -738,9 +738,6 @@
"recents": {
"message": "Senaste"
},
"recipientAddress": {
"message": "Mottagaradress"
},
"recipientAddressPlaceholder": {
"message": "Sök, allmän adress (0x) eller ENS"
},

View File

@ -732,9 +732,6 @@
"recents": {
"message": "Za hivi karibuni"
},
"recipientAddress": {
"message": "Anwani ya Mpokeaji"
},
"recipientAddressPlaceholder": {
"message": "Tafuta, anwani za umma (0x), au ENS"
},

View File

@ -372,9 +372,6 @@
"readdToken": {
"message": "உங்கள் கணக்கு விருப்பங்கள் மெனுவில் \"டோக்கனைச் சேர்\" என்பதன் மூலம் நீங்கள் எதிர்காலத்தில் இந்த டோக்கனை மீண்டும் சேர்க்கலாம்."
},
"recipientAddress": {
"message": "பெறுநர் முகவரி"
},
"reject": {
"message": "நிராகரி"
},

View File

@ -375,9 +375,6 @@
"readdToken": {
"message": "คุณสามารถเพิ่มโทเค็นนี้ในอนาคตได้โดยไปที่ “เพิ่มโทเค็น” ในเมนูตัวเลือกบัญชีของคุณ"
},
"recipientAddress": {
"message": "แอดแดรสผู้รับ"
},
"reject": {
"message": "ปฏิเสธ"
},

View File

@ -1192,9 +1192,6 @@
"recents": {
"message": "Mga Kamakailan"
},
"recipientAddress": {
"message": "Address ng Tatanggap"
},
"recipientAddressPlaceholder": {
"message": "Maghanap, pampublikong address (0x), o ENS"
},

View File

@ -324,9 +324,6 @@
"readdToken": {
"message": "Gelecekte Bu jetonu hesap seçenekleri menüsünde “Jeton ekle”'ye giderek geri ekleyebilirsiniz."
},
"recipientAddress": {
"message": "Alıcı adresi"
},
"reject": {
"message": "Reddetmek"
},

View File

@ -754,9 +754,6 @@
"recents": {
"message": "Останні"
},
"recipientAddress": {
"message": "Адреса отримувача"
},
"recipientAddressPlaceholder": {
"message": "Пошук, публічна адреса (0x), або ENS"
},

View File

@ -1411,9 +1411,6 @@
"recents": {
"message": "Gần đây"
},
"recipientAddress": {
"message": "Địa chỉ người nhận"
},
"recipientAddressPlaceholder": {
"message": "Tìm kiếm, địa chỉ công khai (0x) hoặc ENS"
},

View File

@ -1195,9 +1195,6 @@
"recents": {
"message": "最近记录"
},
"recipientAddress": {
"message": "接收地址"
},
"recipientAddressPlaceholder": {
"message": "查找、公用地址 (0x) 或 ENS"
},

View File

@ -751,9 +751,6 @@
"recents": {
"message": "最近"
},
"recipientAddress": {
"message": "接收位址"
},
"recipientAddressPlaceholder": {
"message": "搜尋,公開地址 (0x),或 ENS"
},

View File

@ -0,0 +1,219 @@
const { strict: assert } = require('assert');
const { withFixtures, regularDelayMs } = require('../helpers');
describe('Send ETH from inside MetaMask using default gas', function () {
const ganacheOptions = {
accounts: [
{
secretKey:
'0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC',
balance: 25000000000000000000,
},
],
};
it('finds the transaction in the transactions list', async function () {
await withFixtures(
{
fixtures: 'imported-account',
ganacheOptions,
title: this.test.title,
},
async ({ driver }) => {
await driver.navigate();
await driver.fill('#password', 'correct horse battery staple');
await driver.press('#password', driver.Key.ENTER);
await driver.clickElement('[data-testid="eth-overview-send"]');
await driver.fill(
'input[placeholder="Search, public address (0x), or ENS"]',
'0x2f318C334780961FB129D2a6c30D0763d9a5C970',
);
const inputAmount = await driver.findElement('.unit-input__input');
await inputAmount.fill('1000');
const errorAmount = await driver.findElement('.send-v2__error-amount');
assert.equal(
await errorAmount.getText(),
'Insufficient funds.',
'send screen should render an insufficient fund error message',
);
await inputAmount.press(driver.Key.BACK_SPACE);
await inputAmount.press(driver.Key.BACK_SPACE);
await inputAmount.press(driver.Key.BACK_SPACE);
await driver.delay(regularDelayMs);
await driver.assertElementNotPresent('.send-v2__error-amount');
const amountMax = await driver.findClickableElement(
'.send-v2__amount-max',
);
await amountMax.click();
let inputValue = await inputAmount.getAttribute('value');
assert(Number(inputValue) > 24);
await amountMax.click();
assert.equal(await inputAmount.isEnabled(), true);
await inputAmount.fill('1');
inputValue = await inputAmount.getAttribute('value');
assert.equal(inputValue, '1');
// Continue to next screen
await driver.clickElement({ text: 'Next', tag: 'button' });
await driver.clickElement({ text: 'Confirm', tag: 'button' });
await driver.clickElement('[data-testid="home__activity-tab"]');
await driver.wait(async () => {
const confirmedTxes = await driver.findElements(
'.transaction-list__completed-transactions .transaction-list-item',
);
return confirmedTxes.length === 1;
}, 10000);
await driver.waitForSelector({
css: '.transaction-list-item__primary-currency',
text: '-1 ETH',
});
},
);
});
});
describe('Send ETH from inside MetaMask using fast gas option', function () {
const ganacheOptions = {
accounts: [
{
secretKey:
'0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC',
balance: 25000000000000000000,
},
],
};
it('finds the transaction in the transactions list', async function () {
await withFixtures(
{
fixtures: 'imported-account',
ganacheOptions,
title: this.test.title,
},
async ({ driver }) => {
await driver.navigate();
await driver.fill('#password', 'correct horse battery staple');
await driver.press('#password', driver.Key.ENTER);
await driver.clickElement('[data-testid="eth-overview-send"]');
await driver.fill(
'input[placeholder="Search, public address (0x), or ENS"]',
'0x2f318C334780961FB129D2a6c30D0763d9a5C970',
);
const inputAmount = await driver.findElement('.unit-input__input');
await inputAmount.fill('1');
const inputValue = await inputAmount.getAttribute('value');
assert.equal(inputValue, '1');
// Set the gas price
await driver.clickElement({ text: 'Fast', tag: 'button/div/div' });
// Continue to next screen
await driver.clickElement({ text: 'Next', tag: 'button' });
await driver.clickElement({ text: 'Confirm', tag: 'button' });
await driver.waitForSelector(
'.transaction-list__completed-transactions .transaction-list-item',
);
await driver.waitForSelector({
css: '.transaction-list-item__primary-currency',
text: '-1 ETH',
});
},
);
});
});
describe('Send ETH from inside MetaMask using advanced gas modal', function () {
const ganacheOptions = {
accounts: [
{
secretKey:
'0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC',
balance: 25000000000000000000,
},
],
};
it('finds the transaction in the transactions list', async function () {
await withFixtures(
{
fixtures: 'imported-account',
ganacheOptions,
title: this.test.title,
},
async ({ driver }) => {
await driver.navigate();
await driver.fill('#password', 'correct horse battery staple');
await driver.press('#password', driver.Key.ENTER);
await driver.clickElement('[data-testid="eth-overview-send"]');
await driver.fill(
'input[placeholder="Search, public address (0x), or ENS"]',
'0x2f318C334780961FB129D2a6c30D0763d9a5C970',
);
const inputAmount = await driver.findElement('.unit-input__input');
await inputAmount.fill('1');
const inputValue = await inputAmount.getAttribute('value');
assert.equal(inputValue, '1');
// Set the gas limit
await driver.clickElement('.advanced-gas-options-btn');
// wait for gas modal to be visible
const gasModal = await driver.findVisibleElement('span .modal');
await driver.clickElement({ text: 'Save', tag: 'button' });
// Wait for gas modal to be removed from DOM
await gasModal.waitForElementState('hidden');
// Continue to next screen
await driver.clickElement({ text: 'Next', tag: 'button' });
const transactionAmounts = await driver.findElements(
'.currency-display-component__text',
);
const transactionAmount = transactionAmounts[0];
assert.equal(await transactionAmount.getText(), '1');
await driver.clickElement({ text: 'Confirm', tag: 'button' });
await driver.wait(async () => {
const confirmedTxes = await driver.findElements(
'.transaction-list__completed-transactions .transaction-list-item',
);
return confirmedTxes.length === 1;
}, 10000);
await driver.waitForSelector(
{
css: '.transaction-list-item__primary-currency',
text: '-1 ETH',
},
{ timeout: 10000 },
);
},
);
});
});

View File

@ -10,7 +10,7 @@ import InfoIcon from '../../ui/icon/info-icon.component';
import Button from '../../ui/button';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { useMetricEvent } from '../../../hooks/useMetricEvent';
import { updateSendToken } from '../../../ducks/send/send.duck';
import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send';
import { SEND_ROUTE } from '../../../helpers/constants/routes';
import { SEVERITIES } from '../../../helpers/constants/design-system';
@ -69,13 +69,17 @@ const AssetListItem = ({
e.stopPropagation();
sendTokenEvent();
dispatch(
updateSendToken({
updateSendAsset({
type: ASSET_TYPES.TOKEN,
details: {
address: tokenAddress,
decimals: tokenDecimals,
symbol: tokenSymbol,
},
}),
);
).then(() => {
history.push(SEND_ROUTE);
});
}}
>
{t('sendSpecifiedTokens', [tokenSymbol])}

View File

@ -9,10 +9,10 @@ import {
} from '../../../../ducks/gas/gas.duck';
import {
hideGasButtonGroup,
setGasLimit,
setGasPrice,
} from '../../../../ducks/send/send.duck';
useCustomGas,
updateGasLimit,
updateGasPrice,
} from '../../../../ducks/send';
let mapDispatchToProps;
let mergeProps;
@ -32,8 +32,6 @@ jest.mock('../../../../selectors', () => ({
`mockRenderableBasicEstimateData:${Object.keys(s).length}`,
getDefaultActiveButtonIndex: (a, b) => a + b,
getCurrentEthBalance: (state) => state.metamask.balance || '0x0',
getSendToken: () => null,
getTokenBalance: (state) => state.send.tokenBalance || '0x0',
getCustomGasPrice: (state) => state.gas.customData.price || '0x0',
getCustomGasLimit: (state) => state.gas.customData.limit || '0x0',
getCurrentCurrency: jest.fn().mockReturnValue('usd'),
@ -57,11 +55,15 @@ jest.mock('../../../../ducks/gas/gas.duck', () => ({
resetCustomData: jest.fn(),
}));
jest.mock('../../../../ducks/send/send.duck', () => ({
hideGasButtonGroup: jest.fn(),
setGasLimit: jest.fn(),
setGasPrice: jest.fn(),
}));
jest.mock('../../../../ducks/send', () => {
const { ASSET_TYPES } = jest.requireActual('../../../../ducks/send');
return {
useCustomGas: jest.fn(),
updateGasLimit: jest.fn(),
updateGasPrice: jest.fn(),
getSendAsset: jest.fn(() => ({ type: ASSET_TYPES.NATIVE })),
};
});
require('./gas-modal-page-container.container');
@ -79,11 +81,11 @@ describe('gas-modal-page-container container', () => {
dispatchSpy.resetHistory();
});
describe('hideGasButtonGroup()', () => {
it('should dispatch a hideGasButtonGroup action', () => {
mapDispatchToPropsObject.hideGasButtonGroup();
describe('useCustomGas()', () => {
it('should dispatch a useCustomGas action', () => {
mapDispatchToPropsObject.useCustomGas();
expect(dispatchSpy.calledOnce).toStrictEqual(true);
expect(hideGasButtonGroup).toHaveBeenCalled();
expect(useCustomGas).toHaveBeenCalled();
});
});
@ -126,13 +128,13 @@ describe('gas-modal-page-container container', () => {
});
describe('setGasData()', () => {
it('should dispatch a setGasPrice and setGasLimit action with the correct props', () => {
it('should dispatch a updateGasPrice and updateGasLimit action with the correct props', () => {
mapDispatchToPropsObject.setGasData('ffff', 'aaaa');
expect(dispatchSpy.calledTwice).toStrictEqual(true);
expect(setGasPrice).toHaveBeenCalled();
expect(setGasLimit).toHaveBeenCalled();
expect(setGasLimit).toHaveBeenCalledWith('ffff');
expect(setGasPrice).toHaveBeenCalledWith('aaaa');
expect(updateGasPrice).toHaveBeenCalled();
expect(updateGasLimit).toHaveBeenCalled();
expect(updateGasLimit).toHaveBeenCalledWith('ffff');
expect(updateGasPrice).toHaveBeenCalledWith('aaaa');
});
});
@ -165,7 +167,7 @@ describe('gas-modal-page-container container', () => {
};
dispatchProps = {
updateCustomGasPrice: sinon.spy(),
hideGasButtonGroup: sinon.spy(),
useCustomGas: sinon.spy(),
setGasData: sinon.spy(),
updateConfirmTxGasAndCalculate: sinon.spy(),
someOtherDispatchProp: sinon.spy(),
@ -194,7 +196,7 @@ describe('gas-modal-page-container container', () => {
dispatchProps.updateConfirmTxGasAndCalculate.callCount,
).toStrictEqual(0);
expect(dispatchProps.setGasData.callCount).toStrictEqual(0);
expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0);
expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0);
expect(dispatchProps.hideModal.callCount).toStrictEqual(0);
result.onSubmit();
@ -203,7 +205,7 @@ describe('gas-modal-page-container container', () => {
dispatchProps.updateConfirmTxGasAndCalculate.callCount,
).toStrictEqual(1);
expect(dispatchProps.setGasData.callCount).toStrictEqual(0);
expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0);
expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0);
expect(dispatchProps.hideModal.callCount).toStrictEqual(1);
expect(dispatchProps.updateCustomGasPrice.callCount).toStrictEqual(0);
@ -238,7 +240,7 @@ describe('gas-modal-page-container container', () => {
dispatchProps.updateConfirmTxGasAndCalculate.callCount,
).toStrictEqual(0);
expect(dispatchProps.setGasData.callCount).toStrictEqual(0);
expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0);
expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0);
expect(dispatchProps.cancelAndClose.callCount).toStrictEqual(0);
result.onSubmit('mockNewLimit', 'mockNewPrice');
@ -251,7 +253,7 @@ describe('gas-modal-page-container container', () => {
'mockNewLimit',
'mockNewPrice',
]);
expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(1);
expect(dispatchProps.useCustomGas.callCount).toStrictEqual(1);
expect(dispatchProps.cancelAndClose.callCount).toStrictEqual(1);
expect(dispatchProps.updateCustomGasPrice.callCount).toStrictEqual(0);
@ -278,7 +280,7 @@ describe('gas-modal-page-container container', () => {
dispatchProps.updateConfirmTxGasAndCalculate.callCount,
).toStrictEqual(0);
expect(dispatchProps.setGasData.callCount).toStrictEqual(0);
expect(dispatchProps.hideGasButtonGroup.callCount).toStrictEqual(0);
expect(dispatchProps.useCustomGas.callCount).toStrictEqual(0);
expect(dispatchProps.cancelAndClose.callCount).toStrictEqual(1);
expect(dispatchProps.createSpeedUpTransaction.callCount).toStrictEqual(1);

View File

@ -14,19 +14,21 @@ import {
fetchBasicGasEstimates,
} from '../../../../ducks/gas/gas.duck';
import {
hideGasButtonGroup,
setGasLimit,
setGasPrice,
setGasTotal,
updateSendAmount,
updateSendErrors,
} from '../../../../ducks/send/send.duck';
getSendMaxModeState,
getGasLimit,
getGasPrice,
getSendAmount,
updateGasLimit,
updateGasPrice,
useCustomGas,
getSendAsset,
ASSET_TYPES,
} from '../../../../ducks/send';
import {
conversionRateSelector as getConversionRate,
getCurrentCurrency,
getCurrentEthBalance,
getIsMainnet,
getSendToken,
getPreferences,
getIsTestnet,
getBasicGasEstimateLoadingStatus,
@ -35,8 +37,6 @@ import {
getDefaultActiveButtonIndex,
getRenderableBasicEstimateData,
isCustomPriceSafe,
getTokenBalance,
getSendMaxModeState,
isCustomPriceSafeForCustomNetwork,
getAveragePriceEstimateInHexWEI,
isCustomPriceExcessive,
@ -57,16 +57,15 @@ import {
isBalanceSufficient,
} from '../../../../pages/send/send.utils';
import { MIN_GAS_LIMIT_DEC } from '../../../../pages/send/send.constants';
import { calcMaxAmount } from '../../../../pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils';
import { TRANSACTION_STATUSES } from '../../../../../shared/constants/transaction';
import { GAS_LIMITS } from '../../../../../shared/constants/gas';
import GasModalPageContainer from './gas-modal-page-container.component';
const mapStateToProps = (state, ownProps) => {
const {
metamask: { currentNetworkTxList },
send,
} = state;
const gasLimit = getGasLimit(state);
const gasPrice = getGasPrice(state);
const amount = getSendAmount(state);
const { currentNetworkTxList } = state.metamask;
const { modalState: { props: modalProps } = {} } = state.appState.modal || {};
const { txData = {} } = modalProps || {};
const { transaction = {}, onSubmit } = ownProps;
@ -74,15 +73,15 @@ const mapStateToProps = (state, ownProps) => {
({ id }) => id === (transaction.id || txData.id),
);
const buttonDataLoading = getBasicGasEstimateLoadingStatus(state);
const sendToken = getSendToken(state);
const asset = getSendAsset(state);
// a "default" txParams is used during the send flow, since the transaction doesn't exist yet in that case
const txParams = selectedTransaction?.txParams
? selectedTransaction.txParams
: {
gas: send.gasLimit || GAS_LIMITS.SIMPLE,
gasPrice: send.gasPrice || getAveragePriceEstimateInHexWEI(state, true),
value: sendToken ? '0x0' : send.amount,
gas: gasLimit || GAS_LIMITS.SIMPLE,
gasPrice: gasPrice || getAveragePriceEstimateInHexWEI(state, true),
value: asset.type === ASSET_TYPES.TOKEN ? '0x0' : amount,
};
const { gasPrice: currentGasPrice, gas: currentGasLimit } = txParams;
@ -120,16 +119,15 @@ const mapStateToProps = (state, ownProps) => {
const isMainnet = getIsMainnet(state);
const showFiat = Boolean(isMainnet || showFiatInTestnets);
const isSendTokenSet = Boolean(sendToken);
const isTestnet = getIsTestnet(state);
const newTotalEth =
maxModeOn && !isSendTokenSet
maxModeOn && asset.type === ASSET_TYPES.NATIVE
? sumHexWEIsToRenderableEth([balance, '0x0'])
: sumHexWEIsToRenderableEth([value, customGasTotal]);
const sendAmount =
maxModeOn && !isSendTokenSet
maxModeOn && asset.type === ASSET_TYPES.NATIVE
? subtractHexWEIsFromRenderableEth(balance, customGasTotal)
: sumHexWEIsToRenderableEth([value, '0x0']);
@ -194,9 +192,7 @@ const mapStateToProps = (state, ownProps) => {
txId: transaction.id,
insufficientBalance,
isMainnet,
sendToken,
balance,
tokenBalance: getTokenBalance(state),
conversionRate,
value,
onSubmit,
@ -213,12 +209,13 @@ const mapDispatchToProps = (dispatch) => {
dispatch(hideModal());
},
hideModal: () => dispatch(hideModal()),
useCustomGas: () => dispatch(useCustomGas()),
updateCustomGasPrice,
updateCustomGasLimit: (newLimit) =>
dispatch(setCustomGasLimit(addHexPrefix(newLimit))),
setGasData: (newLimit, newPrice) => {
dispatch(setGasLimit(newLimit));
dispatch(setGasPrice(newPrice));
dispatch(updateGasLimit(newLimit));
dispatch(updateGasPrice(newPrice));
},
updateConfirmTxGasAndCalculate: (gasLimit, gasPrice, updatedTx) => {
updateCustomGasPrice(gasPrice);
@ -231,14 +228,8 @@ const mapDispatchToProps = (dispatch) => {
createSpeedUpTransaction: (txId, gasPrice, gasLimit) => {
return dispatch(createSpeedUpTransaction(txId, gasPrice, gasLimit));
},
hideGasButtonGroup: () => dispatch(hideGasButtonGroup()),
hideSidebar: () => dispatch(hideSidebar()),
fetchBasicGasEstimates: () => dispatch(fetchBasicGasEstimates()),
setGasTotal: (total) => dispatch(setGasTotal(total)),
setAmountToMax: (maxAmountDataObject) => {
dispatch(updateSendErrors({ amount: null }));
dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject)));
},
};
};
@ -251,17 +242,12 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
isSpeedUp,
isRetry,
insufficientBalance,
maxModeOn,
customGasPrice,
customGasTotal,
balance,
sendToken,
tokenBalance,
customGasLimit,
transaction,
} = stateProps;
const {
hideGasButtonGroup: dispatchHideGasButtonGroup,
useCustomGas: dispatchUseCustomGas,
setGasData: dispatchSetGasData,
updateConfirmTxGasAndCalculate: dispatchUpdateConfirmTxGasAndCalculate,
createSpeedUpTransaction: dispatchCreateSpeedUpTransaction,
@ -269,7 +255,6 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
hideSidebar: dispatchHideSidebar,
cancelAndClose: dispatchCancelAndClose,
hideModal: dispatchHideModal,
setAmountToMax: dispatchSetAmountToMax,
...otherDispatchProps
} = dispatchProps;
@ -305,17 +290,9 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
dispatchCancelAndClose();
} else {
dispatchSetGasData(gasLimit, gasPrice);
dispatchHideGasButtonGroup();
dispatchUseCustomGas();
dispatchCancelAndClose();
}
if (maxModeOn) {
dispatchSetAmountToMax({
balance,
gasTotal: customGasTotal,
sendToken,
tokenBalance,
});
}
},
gasPriceButtonGroupProps: {
...gasPriceButtonGroupProps,

View File

@ -17,7 +17,7 @@ import {
} from '../../../hooks/useMetricEvent';
import { useTokenTracker } from '../../../hooks/useTokenTracker';
import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount';
import { updateSendToken } from '../../../ducks/send/send.duck';
import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send';
import { setSwapsFromToken } from '../../../ducks/swaps/swaps';
import {
getAssetImages,
@ -85,8 +85,14 @@ const TokenOverview = ({ className, token }) => {
className="token-overview__button"
onClick={() => {
sendTokenEvent();
dispatch(updateSendToken(token));
dispatch(
updateSendAsset({
type: ASSET_TYPES.TOKEN,
details: token,
}),
).then(() => {
history.push(SEND_ROUTE);
});
}}
Icon={SendIcon}
label={t('send')}

View File

@ -1,7 +1,10 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { removeLeadingZeroes } from '../../../pages/send/send.utils';
function removeLeadingZeroes(str) {
return str.replace(/^0*(?=\d)/u, '');
}
/**
* Component that attaches a suffix or unit of measurement trailing user input, ex. 'ETH'. Also

View File

@ -15,10 +15,11 @@ import {
getNumberOfAccounts,
getNumberOfTokens,
} from '../selectors/selectors';
import { getSendToken } from '../selectors/send';
import { getSendAsset, ASSET_TYPES } from '../ducks/send';
import { txDataSelector } from '../selectors/confirm-transaction';
import { getEnvironmentType } from '../../app/scripts/lib/util';
import { trackMetaMetricsEvent } from '../store/actions';
import { getNativeCurrency } from '../ducks/metamask/metamask';
export const MetaMetricsContext = createContext(() => {
captureException(
@ -31,7 +32,8 @@ export const MetaMetricsContext = createContext(() => {
export function MetaMetricsProvider({ children }) {
const txData = useSelector(txDataSelector) || {};
const environmentType = getEnvironmentType();
const activeCurrency = useSelector(getSendToken)?.symbol;
const activeAsset = useSelector(getSendAsset);
const nativeAssetSymbol = useSelector(getNativeCurrency);
const accountType = useSelector(getAccountType);
const confirmTransactionOrigin = txData.origin;
const numberOfTokens = useSelector(getNumberOfTokens);
@ -72,7 +74,10 @@ export function MetaMetricsProvider({ children }) {
action: eventOpts.action,
number_of_tokens: numberOfTokens,
number_of_accounts: numberOfAccounts,
active_currency: activeCurrency,
active_currency:
activeAsset.type === ASSET_TYPES.NATIVE
? nativeAssetSymbol
: activeAsset?.details?.symbol,
account_type: accountType,
is_new_visit: config.is_new_visit,
// the properties coming from this key will not match our standards for
@ -102,7 +107,8 @@ export function MetaMetricsProvider({ children }) {
accountType,
currentPath,
confirmTransactionOrigin,
activeCurrency,
activeAsset,
nativeAssetSymbol,
numberOfTokens,
numberOfAccounts,
environmentType,

197
ui/ducks/ens.js Normal file
View File

@ -0,0 +1,197 @@
import { createSlice } from '@reduxjs/toolkit';
import ENS from 'ethjs-ens';
import log from 'loglevel';
import networkMap from 'ethereum-ens-network-map';
import { isConfusing } from 'unicode-confusables';
import { isHexString } from 'ethereumjs-util';
import { getCurrentChainId } from '../selectors';
import {
CHAIN_ID_TO_NETWORK_ID_MAP,
MAINNET_NETWORK_ID,
} from '../../shared/constants/network';
import {
CONFUSING_ENS_ERROR,
ENS_ILLEGAL_CHARACTER,
ENS_NOT_FOUND_ON_NETWORK,
ENS_NOT_SUPPORTED_ON_NETWORK,
ENS_NO_ADDRESS_FOR_NAME,
ENS_REGISTRATION_ERROR,
ENS_UNKNOWN_ERROR,
} from '../pages/send/send.constants';
import { isValidDomainName } from '../helpers/utils/util';
import { CHAIN_CHANGED } from '../store/actionConstants';
import {
BURN_ADDRESS,
isBurnAddress,
isValidHexAddress,
} from '../../shared/modules/hexstring-utils';
// Local Constants
const ZERO_X_ERROR_ADDRESS = '0x';
const initialState = {
stage: 'UNINITIALIZED',
resolution: null,
error: null,
warning: null,
network: null,
};
export const ensInitialState = initialState;
const name = 'ENS';
let ens = null;
const slice = createSlice({
name,
initialState,
reducers: {
ensLookup: (state, action) => {
// first clear out the previous state
state.resolution = null;
state.error = null;
state.warning = null;
const { address, ensName, error, network } = action.payload;
if (error) {
if (
isValidDomainName(ensName) &&
error.message === 'ENS name not defined.'
) {
state.error =
network === MAINNET_NETWORK_ID
? ENS_NO_ADDRESS_FOR_NAME
: ENS_NOT_FOUND_ON_NETWORK;
} else if (error.message === 'Illegal Character for ENS.') {
state.error = ENS_ILLEGAL_CHARACTER;
} else {
log.error(error);
state.error = ENS_UNKNOWN_ERROR;
}
} else if (address) {
if (address === BURN_ADDRESS) {
state.error = ENS_NO_ADDRESS_FOR_NAME;
} else if (address === ZERO_X_ERROR_ADDRESS) {
state.error = ENS_REGISTRATION_ERROR;
} else {
state.resolution = address;
}
if (isValidDomainName(address) && isConfusing(address)) {
state.warning = CONFUSING_ENS_ERROR;
}
}
},
enableEnsLookup: (state, action) => {
state.stage = 'INITIALIZED';
state.error = null;
state.resolution = null;
state.warning = null;
state.network = action.payload;
},
disableEnsLookup: (state) => {
state.stage = 'NO_NETWORK_SUPPORT';
state.error = ENS_NOT_SUPPORTED_ON_NETWORK;
state.warning = null;
state.resolution = null;
state.network = null;
},
resetResolution: (state) => {
state.resolution = null;
state.warning = null;
state.error =
state.stage === 'NO_NETWORK_SUPPORT'
? ENS_NOT_SUPPORTED_ON_NETWORK
: null;
},
},
extraReducers: (builder) => {
builder.addCase(CHAIN_CHANGED, (state, action) => {
if (action.payload !== state.currentChainId) {
state.stage = 'UNINITIALIZED';
ens = null;
}
});
},
});
const { reducer, actions } = slice;
export default reducer;
const {
disableEnsLookup,
ensLookup,
enableEnsLookup,
resetResolution,
} = actions;
export { resetResolution };
export function initializeEnsSlice() {
return (dispatch, getState) => {
const state = getState();
const chainId = getCurrentChainId(state);
const network = CHAIN_ID_TO_NETWORK_ID_MAP[chainId];
const networkIsSupported = Boolean(networkMap[network]);
if (networkIsSupported) {
ens = new ENS({ provider: global.ethereumProvider, network });
dispatch(enableEnsLookup(network));
} else {
ens = null;
dispatch(disableEnsLookup());
}
};
}
export function lookupEnsName(ensName) {
return async (dispatch, getState) => {
const trimmedEnsName = ensName.trim();
let state = getState();
if (state[name].stage === 'UNINITIALIZED') {
await dispatch(initializeEnsSlice());
}
state = getState();
if (
state[name].stage === 'NO_NETWORK_SUPPORT' &&
!(
isBurnAddress(trimmedEnsName) === false &&
isValidHexAddress(trimmedEnsName, { mixedCaseUseChecksum: true })
) &&
!isHexString(trimmedEnsName)
) {
await dispatch(resetResolution());
} else {
log.info(`ENS attempting to resolve name: ${trimmedEnsName}`);
let address;
let error;
try {
address = await ens.lookup(trimmedEnsName);
} catch (err) {
error = err;
}
const chainId = getCurrentChainId(state);
const network = CHAIN_ID_TO_NETWORK_ID_MAP[chainId];
await dispatch(
ensLookup({
ensName: trimmedEnsName,
address,
error,
chainId,
network,
}),
);
}
};
}
export function getEnsResolution(state) {
return state[name].resolution;
}
export function getEnsError(state) {
return state[name].error;
}
export function getEnsWarning(state) {
return state[name].warning;
}

View File

@ -0,0 +1,14 @@
// This file has been separated because it is required in both the gas and send
// slices. This created a circular dependency problem as both slices also
// import from the actions and selectors files. This easiest path for
// untangling is having the constants separate.
// Actions
export const BASIC_GAS_ESTIMATE_STATUS =
'metamask/gas/BASIC_GAS_ESTIMATE_STATUS';
export const RESET_CUSTOM_DATA = 'metamask/gas/RESET_CUSTOM_DATA';
export const SET_BASIC_GAS_ESTIMATE_DATA =
'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA';
export const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT';
export const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE';
export const SET_ESTIMATE_SOURCE = 'metamask/gas/SET_ESTIMATE_SOURCE';

View File

@ -10,6 +10,14 @@ import GasReducer, {
fetchBasicGasEstimates,
} from './gas.duck';
import {
BASIC_GAS_ESTIMATE_STATUS,
SET_BASIC_GAS_ESTIMATE_DATA,
SET_CUSTOM_GAS_PRICE,
SET_CUSTOM_GAS_LIMIT,
SET_ESTIMATE_SOURCE,
} from './gas-action-constants';
jest.mock('../../helpers/utils/storage-helpers.js', () => ({
getStorageItem: jest.fn(),
setStorageItem: jest.fn(),
@ -61,13 +69,6 @@ describe('Gas Duck', () => {
type: 'mainnet',
};
const BASIC_GAS_ESTIMATE_STATUS = 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS';
const SET_BASIC_GAS_ESTIMATE_DATA =
'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA';
const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT';
const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE';
const SET_ESTIMATE_SOURCE = 'metamask/gas/SET_ESTIMATE_SOURCE';
describe('GasReducer()', () => {
it('should initialize state', () => {
expect(GasReducer(undefined, {})).toStrictEqual(initState);

View File

@ -10,6 +10,14 @@ import {
} from '../../helpers/utils/conversions.util';
import { getIsMainnet, getCurrentChainId } from '../../selectors';
import fetchWithCache from '../../helpers/utils/fetch-with-cache';
import {
BASIC_GAS_ESTIMATE_STATUS,
RESET_CUSTOM_DATA,
SET_BASIC_GAS_ESTIMATE_DATA,
SET_CUSTOM_GAS_LIMIT,
SET_CUSTOM_GAS_PRICE,
SET_ESTIMATE_SOURCE,
} from './gas-action-constants';
export const BASIC_ESTIMATE_STATES = {
LOADING: 'LOADING',
@ -22,14 +30,6 @@ export const GAS_SOURCE = {
ETHGASPRICE: 'eth_gasprice',
};
// Actions
const BASIC_GAS_ESTIMATE_STATUS = 'metamask/gas/BASIC_GAS_ESTIMATE_STATUS';
const RESET_CUSTOM_DATA = 'metamask/gas/RESET_CUSTOM_DATA';
const SET_BASIC_GAS_ESTIMATE_DATA = 'metamask/gas/SET_BASIC_GAS_ESTIMATE_DATA';
const SET_CUSTOM_GAS_LIMIT = 'metamask/gas/SET_CUSTOM_GAS_LIMIT';
const SET_CUSTOM_GAS_PRICE = 'metamask/gas/SET_CUSTOM_GAS_PRICE';
const SET_ESTIMATE_SOURCE = 'metamask/gas/SET_ESTIMATE_SOURCE';
const initState = {
customData: {
price: null,

View File

@ -2,7 +2,8 @@ import { combineReducers } from 'redux';
import { ALERT_TYPES } from '../../shared/constants/alerts';
import metamaskReducer from './metamask/metamask';
import localeMessagesReducer from './locale/locale';
import sendReducer from './send/send.duck';
import sendReducer from './send/send';
import ensReducer from './ens';
import appStateReducer from './app/app';
import confirmTransactionReducer from './confirm-transaction/confirm-transaction.duck';
import gasReducer from './gas/gas.duck';
@ -16,6 +17,7 @@ export default combineReducers({
activeTab: (s) => (s === undefined ? null : s),
metamask: metamaskReducer,
appState: appStateReducer,
ENS: ensReducer,
history: historyReducer,
send: sendReducer,
confirmTransaction: confirmTransactionReducer,

1
ui/ducks/send/index.js Normal file
View File

@ -0,0 +1 @@
export * from './send';

View File

@ -1,142 +0,0 @@
import SendReducer, {
openToDropdown,
closeToDropdown,
updateSendErrors,
showGasButtonGroup,
hideGasButtonGroup,
} from './send.duck';
describe('Send Duck', () => {
const mockState = {
mockProp: 123,
};
const initState = {
toDropdownOpen: false,
gasButtonGroupShown: true,
errors: {},
gasLimit: null,
gasPrice: null,
gasTotal: null,
tokenBalance: '0x0',
from: '',
to: '',
amount: '0',
memo: '',
maxModeOn: false,
editingTransactionId: null,
toNickname: '',
ensResolution: null,
ensResolutionError: '',
gasIsLoading: false,
};
const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN';
const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN';
const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS';
const RESET_SEND_STATE = 'metamask/send/RESET_SEND_STATE';
const SHOW_GAS_BUTTON_GROUP = 'metamask/send/SHOW_GAS_BUTTON_GROUP';
const HIDE_GAS_BUTTON_GROUP = 'metamask/send/HIDE_GAS_BUTTON_GROUP';
describe('SendReducer()', () => {
it('should initialize state', () => {
expect(SendReducer(undefined, {})).toStrictEqual(initState);
});
it('should return state unchanged if it does not match a dispatched actions type', () => {
expect(
SendReducer(mockState, {
type: 'someOtherAction',
value: 'someValue',
}),
).toStrictEqual(mockState);
});
it('should set toDropdownOpen to true when receiving a OPEN_TO_DROPDOWN action', () => {
expect(
SendReducer(mockState, {
type: OPEN_TO_DROPDOWN,
}),
).toStrictEqual({ toDropdownOpen: true, ...mockState });
});
it('should set toDropdownOpen to false when receiving a CLOSE_TO_DROPDOWN action', () => {
expect(
SendReducer(mockState, {
type: CLOSE_TO_DROPDOWN,
}),
).toStrictEqual({ toDropdownOpen: false, ...mockState });
});
it('should set gasButtonGroupShown to true when receiving a SHOW_GAS_BUTTON_GROUP action', () => {
expect(
SendReducer(
{ ...mockState, gasButtonGroupShown: false },
{ type: SHOW_GAS_BUTTON_GROUP },
),
).toStrictEqual({ gasButtonGroupShown: true, ...mockState });
});
it('should set gasButtonGroupShown to false when receiving a HIDE_GAS_BUTTON_GROUP action', () => {
expect(
SendReducer(mockState, { type: HIDE_GAS_BUTTON_GROUP }),
).toStrictEqual({ gasButtonGroupShown: false, ...mockState });
});
it('should extend send.errors with the value of a UPDATE_SEND_ERRORS action', () => {
const modifiedMockState = {
...mockState,
errors: {
someError: false,
},
};
expect(
SendReducer(modifiedMockState, {
type: UPDATE_SEND_ERRORS,
value: { someOtherError: true },
}),
).toStrictEqual({
...modifiedMockState,
errors: {
someError: false,
someOtherError: true,
},
});
});
it('should return the initial state in response to a RESET_SEND_STATE action', () => {
expect(
SendReducer(mockState, {
type: RESET_SEND_STATE,
}),
).toStrictEqual(initState);
});
});
describe('Send Duck Actions', () => {
it('calls openToDropdown action', () => {
expect(openToDropdown()).toStrictEqual({ type: OPEN_TO_DROPDOWN });
});
it('calls closeToDropdown action', () => {
expect(closeToDropdown()).toStrictEqual({ type: CLOSE_TO_DROPDOWN });
});
it('calls showGasButtonGroup action', () => {
expect(showGasButtonGroup()).toStrictEqual({
type: SHOW_GAS_BUTTON_GROUP,
});
});
it('calls hideGasButtonGroup action', () => {
expect(hideGasButtonGroup()).toStrictEqual({
type: HIDE_GAS_BUTTON_GROUP,
});
});
it('calls updateSendErrors action', () => {
expect(updateSendErrors('mockErrorObject')).toStrictEqual({
type: UPDATE_SEND_ERRORS,
value: 'mockErrorObject',
});
});
});
});

View File

@ -1,382 +0,0 @@
import log from 'loglevel';
import { estimateGas } from '../../store/actions';
import { setCustomGasLimit } from '../gas/gas.duck';
import {
estimateGasForSend,
calcTokenBalance,
} from '../../pages/send/send.utils';
// Actions
const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN';
const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN';
const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS';
const RESET_SEND_STATE = 'metamask/send/RESET_SEND_STATE';
const SHOW_GAS_BUTTON_GROUP = 'metamask/send/SHOW_GAS_BUTTON_GROUP';
const HIDE_GAS_BUTTON_GROUP = 'metamask/send/HIDE_GAS_BUTTON_GROUP';
const UPDATE_GAS_LIMIT = 'UPDATE_GAS_LIMIT';
const UPDATE_GAS_PRICE = 'UPDATE_GAS_PRICE';
const UPDATE_GAS_TOTAL = 'UPDATE_GAS_TOTAL';
const UPDATE_SEND_HEX_DATA = 'UPDATE_SEND_HEX_DATA';
const UPDATE_SEND_TOKEN_BALANCE = 'UPDATE_SEND_TOKEN_BALANCE';
const UPDATE_SEND_TO = 'UPDATE_SEND_TO';
const UPDATE_SEND_AMOUNT = 'UPDATE_SEND_AMOUNT';
const UPDATE_MAX_MODE = 'UPDATE_MAX_MODE';
const UPDATE_SEND = 'UPDATE_SEND';
const UPDATE_SEND_TOKEN = 'UPDATE_SEND_TOKEN';
const CLEAR_SEND = 'CLEAR_SEND';
const GAS_LOADING_STARTED = 'GAS_LOADING_STARTED';
const GAS_LOADING_FINISHED = 'GAS_LOADING_FINISHED';
const UPDATE_SEND_ENS_RESOLUTION = 'UPDATE_SEND_ENS_RESOLUTION';
const UPDATE_SEND_ENS_RESOLUTION_ERROR = 'UPDATE_SEND_ENS_RESOLUTION_ERROR';
const initState = {
toDropdownOpen: false,
gasButtonGroupShown: true,
errors: {},
gasLimit: null,
gasPrice: null,
gasTotal: null,
tokenBalance: '0x0',
from: '',
to: '',
amount: '0',
memo: '',
maxModeOn: false,
editingTransactionId: null,
toNickname: '',
ensResolution: null,
ensResolutionError: '',
gasIsLoading: false,
};
// Reducer
export default function reducer(state = initState, action) {
switch (action.type) {
case OPEN_TO_DROPDOWN:
return {
...state,
toDropdownOpen: true,
};
case CLOSE_TO_DROPDOWN:
return {
...state,
toDropdownOpen: false,
};
case UPDATE_SEND_ERRORS:
return {
...state,
errors: {
...state.errors,
...action.value,
},
};
case SHOW_GAS_BUTTON_GROUP:
return {
...state,
gasButtonGroupShown: true,
};
case HIDE_GAS_BUTTON_GROUP:
return {
...state,
gasButtonGroupShown: false,
};
case UPDATE_GAS_LIMIT:
return {
...state,
gasLimit: action.value,
};
case UPDATE_GAS_PRICE:
return {
...state,
gasPrice: action.value,
};
case RESET_SEND_STATE:
return { ...initState };
case UPDATE_GAS_TOTAL:
return {
...state,
gasTotal: action.value,
};
case UPDATE_SEND_TOKEN_BALANCE:
return {
...state,
tokenBalance: action.value,
};
case UPDATE_SEND_HEX_DATA:
return {
...state,
data: action.value,
};
case UPDATE_SEND_TO:
return {
...state,
to: action.value.to,
toNickname: action.value.nickname,
};
case UPDATE_SEND_AMOUNT:
return {
...state,
amount: action.value,
};
case UPDATE_MAX_MODE:
return {
...state,
maxModeOn: action.value,
};
case UPDATE_SEND:
return Object.assign(state, action.value);
case UPDATE_SEND_TOKEN: {
const newSend = {
...state,
token: action.value,
};
// erase token-related state when switching back to native currency
if (newSend.editingTransactionId && !newSend.token) {
const unapprovedTx =
newSend?.unapprovedTxs?.[newSend.editingTransactionId] || {};
const txParams = unapprovedTx.txParams || {};
Object.assign(newSend, {
tokenBalance: null,
balance: '0',
from: unapprovedTx.from || '',
unapprovedTxs: {
...newSend.unapprovedTxs,
[newSend.editingTransactionId]: {
...unapprovedTx,
txParams: {
...txParams,
data: '',
},
},
},
});
}
return Object.assign(state, newSend);
}
case UPDATE_SEND_ENS_RESOLUTION:
return {
...state,
ensResolution: action.payload,
ensResolutionError: '',
};
case UPDATE_SEND_ENS_RESOLUTION_ERROR:
return {
...state,
ensResolution: null,
ensResolutionError: action.payload,
};
case CLEAR_SEND:
return {
...state,
gasLimit: null,
gasPrice: null,
gasTotal: null,
tokenBalance: null,
from: '',
to: '',
amount: '0x0',
memo: '',
errors: {},
maxModeOn: false,
editingTransactionId: null,
toNickname: '',
};
case GAS_LOADING_STARTED:
return {
...state,
gasIsLoading: true,
};
case GAS_LOADING_FINISHED:
return {
...state,
gasIsLoading: false,
};
default:
return state;
}
}
// Action Creators
export function openToDropdown() {
return { type: OPEN_TO_DROPDOWN };
}
export function closeToDropdown() {
return { type: CLOSE_TO_DROPDOWN };
}
export function showGasButtonGroup() {
return { type: SHOW_GAS_BUTTON_GROUP };
}
export function hideGasButtonGroup() {
return { type: HIDE_GAS_BUTTON_GROUP };
}
export function updateSendErrors(errorObject) {
return {
type: UPDATE_SEND_ERRORS,
value: errorObject,
};
}
export function resetSendState() {
return { type: RESET_SEND_STATE };
}
export function setGasLimit(gasLimit) {
return {
type: UPDATE_GAS_LIMIT,
value: gasLimit,
};
}
export function setGasPrice(gasPrice) {
return {
type: UPDATE_GAS_PRICE,
value: gasPrice,
};
}
export function setGasTotal(gasTotal) {
return {
type: UPDATE_GAS_TOTAL,
value: gasTotal,
};
}
export function updateGasData({
gasPrice,
blockGasLimit,
selectedAddress,
sendToken,
to,
value,
data,
}) {
return (dispatch) => {
dispatch(gasLoadingStarted());
return estimateGasForSend({
estimateGasMethod: estimateGas,
blockGasLimit,
selectedAddress,
sendToken,
to,
value,
estimateGasPrice: gasPrice,
data,
})
.then((gas) => {
dispatch(setGasLimit(gas));
dispatch(setCustomGasLimit(gas));
dispatch(updateSendErrors({ gasLoadingError: null }));
dispatch(gasLoadingFinished());
})
.catch((err) => {
log.error(err);
dispatch(updateSendErrors({ gasLoadingError: 'gasLoadingError' }));
dispatch(gasLoadingFinished());
});
};
}
export function gasLoadingStarted() {
return {
type: GAS_LOADING_STARTED,
};
}
export function gasLoadingFinished() {
return {
type: GAS_LOADING_FINISHED,
};
}
export function updateSendTokenBalance({ sendToken, tokenContract, address }) {
return (dispatch) => {
const tokenBalancePromise = tokenContract
? tokenContract.balanceOf(address)
: Promise.resolve();
return tokenBalancePromise
.then((usersToken) => {
if (usersToken) {
const newTokenBalance = calcTokenBalance({ sendToken, usersToken });
dispatch(setSendTokenBalance(newTokenBalance));
}
})
.catch((err) => {
log.error(err);
updateSendErrors({ tokenBalance: 'tokenBalanceError' });
});
};
}
export function setSendTokenBalance(tokenBalance) {
return {
type: UPDATE_SEND_TOKEN_BALANCE,
value: tokenBalance,
};
}
export function updateSendHexData(value) {
return {
type: UPDATE_SEND_HEX_DATA,
value,
};
}
export function updateSendTo(to, nickname = '') {
return {
type: UPDATE_SEND_TO,
value: { to, nickname },
};
}
export function updateSendAmount(amount) {
return {
type: UPDATE_SEND_AMOUNT,
value: amount,
};
}
export function setMaxModeTo(bool) {
return {
type: UPDATE_MAX_MODE,
value: bool,
};
}
export function updateSend(newSend) {
return {
type: UPDATE_SEND,
value: newSend,
};
}
export function updateSendToken(token) {
return {
type: UPDATE_SEND_TOKEN,
value: token,
};
}
export function clearSend() {
return {
type: CLEAR_SEND,
};
}
export function updateSendEnsResolution(ensResolution) {
return {
type: UPDATE_SEND_ENS_RESOLUTION,
payload: ensResolution,
};
}
export function updateSendEnsResolutionError(errorMessage) {
return {
type: UPDATE_SEND_ENS_RESOLUTION_ERROR,
payload: errorMessage,
};
}

1472
ui/ducks/send/send.js Normal file

File diff suppressed because it is too large Load Diff

1808
ui/ducks/send/send.test.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
import { connect } from 'react-redux';
import { compose } from 'redux';
import { withRouter } from 'react-router-dom';
import { updateSend } from '../../ducks/send/send.duck';
import { ASSET_TYPES, editTransaction } from '../../ducks/send';
import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck';
import ConfirmSendEther from './confirm-send-ether.component';
@ -18,22 +18,8 @@ const mapStateToProps = (state) => {
const mapDispatchToProps = (dispatch) => {
return {
editTransaction: (txData) => {
const { id, txParams } = txData;
const { from, gas: gasLimit, gasPrice, to, value: amount } = txParams;
dispatch(
updateSend({
from,
gasLimit,
gasPrice,
gasTotal: null,
to,
amount,
errors: { to: null, amount: null },
editingTransactionId: id?.toString(),
}),
);
const { id } = txData;
dispatch(editTransaction(ASSET_TYPES.NATIVE, id.toString()));
dispatch(clearConfirmTransaction());
},
};

View File

@ -3,13 +3,8 @@ import { compose } from 'redux';
import { withRouter } from 'react-router-dom';
import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck';
import { showSendTokenPage } from '../../store/actions';
import { conversionUtil } from '../../helpers/utils/conversion-util';
import {
getTokenValueParam,
getTokenAddressParam,
} from '../../helpers/utils/token-util';
import { ASSET_TYPES, editTransaction } from '../../ducks/send';
import { sendTokenTokenAmountAndToAddressSelector } from '../../selectors';
import { updateSend } from '../../ducks/send/send.duck';
import ConfirmSendToken from './confirm-send-token.component';
const mapStateToProps = (state) => {
@ -22,35 +17,15 @@ const mapStateToProps = (state) => {
const mapDispatchToProps = (dispatch) => {
return {
editTransaction: ({ txData, tokenData, tokenProps }) => {
const {
id,
txParams: { from, to: tokenAddress, gas: gasLimit, gasPrice } = {},
} = txData;
const to = getTokenValueParam(tokenData);
const tokenAmountInDec = getTokenAddressParam(tokenData);
const tokenAmountInHex = conversionUtil(tokenAmountInDec, {
fromNumericBase: 'dec',
toNumericBase: 'hex',
});
editTransaction: ({ txData, tokenData, tokenProps: assetDetails }) => {
const { id } = txData;
dispatch(
updateSend({
from,
gasLimit,
gasPrice,
gasTotal: null,
to,
amount: tokenAmountInHex,
errors: { to: null, amount: null },
editingTransactionId: id?.toString(),
token: {
...tokenProps,
address: tokenAddress,
},
}),
editTransaction(
ASSET_TYPES.TOKEN,
id.toString(),
tokenData,
assetDetails,
),
);
dispatch(clearConfirmTransaction());
dispatch(showSendTokenPage());

View File

@ -12,6 +12,7 @@ import Loading from '../../components/ui/loading-screen';
import { getMostRecentOverviewPage } from '../../ducks/history/history';
import { MESSAGE_TYPE } from '../../../shared/constants/app';
import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction';
import { getSendTo } from '../../ducks/send';
function mapStateToProps(state) {
const { metamask, appState } = state;
@ -38,7 +39,7 @@ function mapStateToProps(state) {
unapprovedMsgCount,
unapprovedPersonalMsgCount,
unapprovedTypedMessagesCount,
send: state.send,
sendTo: getSendTo(state),
currentNetworkTxList: state.metamask.currentNetworkTxList,
};
}
@ -68,9 +69,7 @@ class ConfirmTxScreen extends Component {
history: PropTypes.object,
identities: PropTypes.object,
dispatch: PropTypes.func.isRequired,
send: PropTypes.shape({
to: PropTypes.string,
}).isRequired,
sendTo: PropTypes.string,
};
getUnapprovedMessagesTotal() {
@ -182,13 +181,13 @@ class ConfirmTxScreen extends Component {
mostRecentOverviewPage,
network,
chainId,
send,
sendTo,
} = this.props;
const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network, chainId);
if (
unconfTxList.length === 0 &&
!send.to &&
!sendTo &&
this.getUnapprovedMessagesTotal() === 0
) {
history.push(mostRecentOverviewPage);
@ -201,7 +200,7 @@ class ConfirmTxScreen extends Component {
network,
chainId,
currentNetworkTxList,
send,
sendTo,
history,
match: { params: { id: transactionId } = {} },
mostRecentOverviewPage,
@ -241,7 +240,7 @@ class ConfirmTxScreen extends Component {
if (
unconfTxList.length === 0 &&
!send.to &&
!sendTo &&
this.getUnapprovedMessagesTotal() === 0
) {
this.props.history.push(mostRecentOverviewPage);

View File

@ -35,7 +35,7 @@ export default class ConfirmTransaction extends Component {
static propTypes = {
history: PropTypes.object.isRequired,
totalUnapprovedCount: PropTypes.number.isRequired,
send: PropTypes.object,
sendTo: PropTypes.string,
setTransactionToConfirm: PropTypes.func,
clearConfirmTransaction: PropTypes.func,
fetchBasicGasEstimates: PropTypes.func,
@ -52,7 +52,7 @@ export default class ConfirmTransaction extends Component {
componentDidMount() {
const {
totalUnapprovedCount = 0,
send = {},
sendTo,
history,
mostRecentOverviewPage,
transaction: { txParams: { data, to } = {} } = {},
@ -64,7 +64,7 @@ export default class ConfirmTransaction extends Component {
isTokenMethodAction,
} = this.props;
if (!totalUnapprovedCount && !send.to) {
if (!totalUnapprovedCount && !sendTo) {
history.replace(mostRecentOverviewPage);
return;
}

View File

@ -15,17 +15,18 @@ import {
} from '../../store/actions';
import { unconfirmedTransactionsListSelector } from '../../selectors';
import { getMostRecentOverviewPage } from '../../ducks/history/history';
import { getSendTo } from '../../ducks/send';
import ConfirmTransaction from './confirm-transaction.component';
const mapStateToProps = (state, ownProps) => {
const {
metamask: { unapprovedTxs },
send,
} = state;
const {
match: { params = {} },
} = ownProps;
const { id } = params;
const sendTo = getSendTo(state);
const unconfirmedTransactions = unconfirmedTransactionsListSelector(state);
const totalUnconfirmed = unconfirmedTransactions.length;
@ -36,7 +37,7 @@ const mapStateToProps = (state, ownProps) => {
return {
totalUnapprovedCount: totalUnconfirmed,
send,
sendTo,
unapprovedTxs,
id,
mostRecentOverviewPage: getMostRecentOverviewPage(state),

View File

@ -1 +1 @@
export { default } from './send.container';
export { default } from './send';

View File

@ -8,26 +8,28 @@ import RecipientGroup from '../../../../components/app/contact-list/recipient-gr
import { ellipsify } from '../../send.utils';
import Button from '../../../../components/ui/button';
import Confusable from '../../../../components/ui/confusable';
import {
isBurnAddress,
isValidHexAddress,
} from '../../../../../shared/modules/hexstring-utils';
export default class AddRecipient extends Component {
static propTypes = {
query: PropTypes.string,
userInput: PropTypes.string,
ownedAccounts: PropTypes.array,
addressBook: PropTypes.array,
updateGas: PropTypes.func,
updateSendTo: PropTypes.func,
updateRecipient: PropTypes.func,
ensResolution: PropTypes.string,
toError: PropTypes.string,
toWarning: PropTypes.string,
ensResolutionError: PropTypes.string,
ensError: PropTypes.string,
ensWarning: PropTypes.string,
addressBookEntryName: PropTypes.string,
contacts: PropTypes.array,
nonContacts: PropTypes.array,
setInternalSearch: PropTypes.func,
useMyAccountsForRecipientSearch: PropTypes.func,
useContactListForRecipientSearch: PropTypes.func,
isUsingMyAccountsForRecipientSearch: PropTypes.bool,
recipient: PropTypes.shape({
address: PropTypes.string,
nickname: PropTypes.nickname,
error: PropTypes.string,
warning: PropTypes.string,
}),
};
constructor(props) {
@ -61,60 +63,58 @@ export default class AddRecipient extends Component {
metricsEvent: PropTypes.func,
};
state = {
isShowingTransfer: false,
};
selectRecipient = (to, nickname = '') => {
const { updateSendTo, updateGas } = this.props;
updateSendTo(to, nickname);
updateGas({ to });
selectRecipient = (address, nickname = '') => {
this.props.updateRecipient({ address, nickname });
};
searchForContacts = () => {
const { query, contacts } = this.props;
const { userInput, contacts } = this.props;
let _contacts = contacts;
if (query) {
if (userInput) {
this.contactFuse.setCollection(contacts);
_contacts = this.contactFuse.search(query);
_contacts = this.contactFuse.search(userInput);
}
return _contacts;
};
searchForRecents = () => {
const { query, nonContacts } = this.props;
const { userInput, nonContacts } = this.props;
let _nonContacts = nonContacts;
if (query) {
if (userInput) {
this.recentFuse.setCollection(nonContacts);
_nonContacts = this.recentFuse.search(query);
_nonContacts = this.recentFuse.search(userInput);
}
return _nonContacts;
};
render() {
const { ensResolution, query, addressBookEntryName } = this.props;
const { isShowingTransfer } = this.state;
const {
ensResolution,
recipient,
userInput,
addressBookEntryName,
isUsingMyAccountsForRecipientSearch,
} = this.props;
let content;
if (
!isBurnAddress(query) &&
isValidHexAddress(query, { mixedCaseUseChecksum: true })
) {
content = this.renderExplicitAddress(query);
if (recipient.address) {
content = this.renderExplicitAddress(
recipient.address,
recipient.nickname,
);
} else if (ensResolution) {
content = this.renderExplicitAddress(
ensResolution,
addressBookEntryName || query,
addressBookEntryName || userInput,
);
} else if (isShowingTransfer) {
} else if (isUsingMyAccountsForRecipientSearch) {
content = this.renderTransfer();
}
@ -150,15 +150,18 @@ export default class AddRecipient extends Component {
renderTransfer() {
let { ownedAccounts } = this.props;
const { query, setInternalSearch } = this.props;
const {
userInput,
useContactListForRecipientSearch,
isUsingMyAccountsForRecipientSearch,
} = this.props;
const { t } = this.context;
const { isShowingTransfer } = this.state;
if (isShowingTransfer && query) {
if (isUsingMyAccountsForRecipientSearch && userInput) {
ownedAccounts = ownedAccounts.filter(
(item) =>
item.name.toLowerCase().indexOf(query.toLowerCase()) > -1 ||
item.address.toLowerCase().indexOf(query.toLowerCase()) > -1,
item.name.toLowerCase().indexOf(userInput.toLowerCase()) > -1 ||
item.address.toLowerCase().indexOf(userInput.toLowerCase()) > -1,
);
}
@ -167,10 +170,7 @@ export default class AddRecipient extends Component {
<Button
type="link"
className="send__select-recipient-wrapper__list__link"
onClick={() => {
setInternalSearch(false);
this.setState({ isShowingTransfer: false });
}}
onClick={useContactListForRecipientSearch}
>
<div className="send__select-recipient-wrapper__list__back-caret" />
{t('backToAll')}
@ -187,10 +187,10 @@ export default class AddRecipient extends Component {
renderMain() {
const { t } = this.context;
const {
query,
userInput,
ownedAccounts = [],
addressBook,
setInternalSearch,
useMyAccountsForRecipientSearch,
} = this.props;
return (
@ -201,14 +201,11 @@ export default class AddRecipient extends Component {
searchForRecents={this.searchForRecents.bind(this)}
selectRecipient={this.selectRecipient.bind(this)}
>
{ownedAccounts && ownedAccounts.length > 1 && !query && (
{ownedAccounts && ownedAccounts.length > 1 && !userInput && (
<Button
type="link"
className="send__select-recipient-wrapper__list__link"
onClick={() => {
setInternalSearch(true);
this.setState({ isShowingTransfer: true });
}}
onClick={useMyAccountsForRecipientSearch}
>
{t('transferBetweenAccounts')}
</Button>
@ -219,30 +216,19 @@ export default class AddRecipient extends Component {
}
renderDialogs() {
const {
toError,
toWarning,
ensResolutionError,
ensResolution,
} = this.props;
const { ensError, recipient, ensWarning } = this.props;
const { t } = this.context;
if (ensResolutionError) {
if (ensError || (recipient.error && recipient.error !== 'required')) {
return (
<Dialog type="error" className="send__error-dialog">
{ensResolutionError}
{t(ensError ?? recipient.error)}
</Dialog>
);
} else if (toError && toError !== 'required' && !ensResolution) {
return (
<Dialog type="error" className="send__error-dialog">
{t(toError)}
</Dialog>
);
} else if (toWarning) {
} else if (ensWarning || recipient.warning) {
return (
<Dialog type="warning" className="send__error-dialog">
{t(toWarning)}
{t(ensWarning ?? recipient.warning)}
</Dialog>
);
}

View File

@ -5,30 +5,24 @@ import Dialog from '../../../../components/ui/dialog';
import AddRecipient from './add-recipient.component';
const propsMethodSpies = {
closeToDropdown: sinon.spy(),
openToDropdown: sinon.spy(),
updateGas: sinon.spy(),
updateSendTo: sinon.spy(),
updateSendToError: sinon.spy(),
updateSendToWarning: sinon.spy(),
updateRecipient: sinon.spy(),
useMyAccountsForRecipientSearch: sinon.spy(),
useContactListForRecipientSearch: sinon.spy(),
};
describe('AddRecipient Component', () => {
let wrapper;
let instance;
beforeEach(() => {
wrapper = shallow(
<AddRecipient
closeToDropdown={propsMethodSpies.closeToDropdown}
inError={false}
inWarning={false}
network="mockNetwork"
openToDropdown={propsMethodSpies.openToDropdown}
to="mockTo"
toAccounts={['mockAccount']}
toDropdownOpen={false}
updateGas={propsMethodSpies.updateGas}
userInput=""
recipient={{
address: '',
nickname: '',
error: '',
warning: '',
}}
updateSendTo={propsMethodSpies.updateSendTo}
updateSendToError={propsMethodSpies.updateSendToError}
updateSendToWarning={propsMethodSpies.updateSendToWarning}
@ -53,34 +47,12 @@ describe('AddRecipient Component', () => {
/>,
{ context: { t: (str) => `${str}_t` } },
);
instance = wrapper.instance();
});
afterEach(() => {
propsMethodSpies.closeToDropdown.resetHistory();
propsMethodSpies.openToDropdown.resetHistory();
propsMethodSpies.updateSendTo.resetHistory();
propsMethodSpies.updateSendToError.resetHistory();
propsMethodSpies.updateSendToWarning.resetHistory();
propsMethodSpies.updateGas.resetHistory();
});
describe('selectRecipient', () => {
it('should call updateSendTo', () => {
expect(propsMethodSpies.updateSendTo.callCount).toStrictEqual(0);
instance.selectRecipient('mockTo2', 'mockNickname');
expect(propsMethodSpies.updateSendTo.callCount).toStrictEqual(1);
expect(propsMethodSpies.updateSendTo.getCall(0).args).toStrictEqual([
'mockTo2',
'mockNickname',
]);
});
it('should call updateGas if there is no to error', () => {
expect(propsMethodSpies.updateGas.callCount).toStrictEqual(0);
instance.selectRecipient(false);
expect(propsMethodSpies.updateGas.callCount).toStrictEqual(1);
});
propsMethodSpies.updateRecipient.resetHistory();
propsMethodSpies.useMyAccountsForRecipientSearch.resetHistory();
propsMethodSpies.useContactListForRecipientSearch.resetHistory();
});
describe('render', () => {
@ -104,6 +76,7 @@ describe('AddRecipient Component', () => {
it('should render transfer', () => {
wrapper.setProps({
isUsingMyAccountsForRecipientSearch: true,
ownedAccounts: [
{ address: '0x123', name: '123' },
{ address: '0x124', name: '124' },
@ -163,7 +136,7 @@ describe('AddRecipient Component', () => {
it('should render error when query has no results', () => {
wrapper.setProps({
addressBook: [],
toError: 'bad',
ensError: 'bad',
contacts: [],
nonContacts: [],
});
@ -178,8 +151,7 @@ describe('AddRecipient Component', () => {
it('should render error when query has ens does not resolve', () => {
wrapper.setProps({
addressBook: [],
toError: 'bad',
ensResolutionError: 'very bad',
ensError: 'very bad',
contacts: [],
nonContacts: [],
});
@ -187,20 +159,20 @@ describe('AddRecipient Component', () => {
const dialog = wrapper.find(Dialog);
expect(dialog.props().type).toStrictEqual('error');
expect(dialog.props().children).toStrictEqual('very bad');
expect(dialog.props().children).toStrictEqual('very bad_t');
expect(dialog).toHaveLength(1);
});
it('should not render error when ens resolved', () => {
it('should render error when ens resolved but ens error exists', () => {
wrapper.setProps({
addressBook: [],
toError: 'bad',
ensError: 'bad',
ensResolution: '0x128',
});
const dialog = wrapper.find(Dialog);
expect(dialog).toHaveLength(0);
expect(dialog).toHaveLength(1);
});
});
});

View File

@ -1,19 +1,30 @@
import { connect } from 'react-redux';
import {
getSendEnsResolution,
getSendEnsResolutionError,
accountsWithSendEtherInfoSelector,
getAddressBook,
getAddressBookEntry,
} from '../../../../selectors';
import { updateSendTo } from '../../../../ducks/send/send.duck';
import {
updateRecipient,
updateRecipientUserInput,
useMyAccountsForRecipientSearch,
useContactListForRecipientSearch,
getIsUsingMyAccountForRecipientSearch,
getRecipientUserInput,
getRecipient,
} from '../../../../ducks/send';
import {
getEnsResolution,
getEnsError,
getEnsWarning,
} from '../../../../ducks/ens';
import AddRecipient from './add-recipient.component';
export default connect(mapStateToProps, mapDispatchToProps)(AddRecipient);
function mapStateToProps(state) {
const ensResolution = getSendEnsResolution(state);
const ensResolution = getEnsResolution(state);
let addressBookEntryName = '';
if (ensResolution) {
@ -32,14 +43,27 @@ function mapStateToProps(state) {
addressBookEntryName,
contacts: addressBook.filter(({ name }) => Boolean(name)),
ensResolution,
ensResolutionError: getSendEnsResolutionError(state),
ensError: getEnsError(state),
ensWarning: getEnsWarning(state),
nonContacts: addressBook.filter(({ name }) => !name),
ownedAccounts,
isUsingMyAccountsForRecipientSearch: getIsUsingMyAccountForRecipientSearch(
state,
),
userInput: getRecipientUserInput(state),
recipient: getRecipient(state),
};
}
function mapDispatchToProps(dispatch) {
return {
updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)),
updateRecipient: ({ address, nickname }) =>
dispatch(updateRecipient({ address, nickname })),
updateRecipientUserInput: (newInput) =>
dispatch(updateRecipientUserInput(newInput)),
useMyAccountsForRecipientSearch: () =>
dispatch(useMyAccountsForRecipientSearch()),
useContactListForRecipientSearch: () =>
dispatch(useContactListForRecipientSearch()),
};
}

View File

@ -1,6 +1,3 @@
import sinon from 'sinon';
import { updateSendTo } from '../../../../ducks/send/send.duck';
let mapStateToProps;
let mapDispatchToProps;
@ -13,8 +10,6 @@ jest.mock('react-redux', () => ({
}));
jest.mock('../../../../selectors', () => ({
getSendEnsResolution: (s) => `mockSendEnsResolution:${s}`,
getSendEnsResolutionError: (s) => `mockSendEnsResolutionError:${s}`,
getAddressBook: (s) => [{ name: `mockAddressBook:${s}` }],
getAddressBookEntry: (s) => `mockAddressBookEntry:${s}`,
accountsWithSendEtherInfoSelector: () => [
@ -23,8 +18,26 @@ jest.mock('../../../../selectors', () => ({
],
}));
jest.mock('../../../../ducks/send/send.duck.js', () => ({
updateSendTo: jest.fn(),
jest.mock('../../../../ducks/ens', () => ({
getEnsResolution: (s) => `mockSendEnsResolution:${s}`,
getEnsError: (s) => `mockSendEnsResolutionError:${s}`,
getEnsWarning: (s) => `mockSendEnsResolutionWarning:${s}`,
useMyAccountsForRecipientSearch: (s) =>
`useMyAccountsForRecipientSearch:${s}`,
}));
jest.mock('../../../../ducks/send', () => ({
updateRecipient: ({ address, nickname }) =>
`{mockUpdateRecipient: {address: ${address}, nickname: ${nickname}}}`,
updateRecipientUserInput: (s) => `mockUpdateRecipientUserInput:${s}`,
useMyAccountsForRecipientSearch: (s) =>
`mockUseMyAccountsForRecipientSearch:${s}`,
useContactListForRecipientSearch: (s) =>
`mockUseContactListForRecipientSearch:${s}`,
getIsUsingMyAccountForRecipientSearch: (s) =>
`mockGetIsUsingMyAccountForRecipientSearch:${s}`,
getRecipientUserInput: (s) => `mockRecipientUserInput:${s}`,
getRecipient: (s) => `mockRecipient:${s}`,
}));
require('./add-recipient.container.js');
@ -34,29 +47,40 @@ describe('add-recipient container', () => {
it('should map the correct properties to props', () => {
expect(mapStateToProps('mockState')).toStrictEqual({
addressBook: [{ name: 'mockAddressBook:mockState' }],
addressBookEntryName: undefined,
contacts: [{ name: 'mockAddressBook:mockState' }],
ensResolution: 'mockSendEnsResolution:mockState',
ensResolutionError: 'mockSendEnsResolutionError:mockState',
ownedAccounts: [
{ name: `account1:mockState` },
{ name: `account2:mockState` },
],
addressBookEntryName: undefined,
ensError: 'mockSendEnsResolutionError:mockState',
ensWarning: 'mockSendEnsResolutionWarning:mockState',
nonContacts: [],
ownedAccounts: [
{ name: 'account1:mockState' },
{ name: 'account2:mockState' },
],
isUsingMyAccountsForRecipientSearch:
'mockGetIsUsingMyAccountForRecipientSearch:mockState',
userInput: 'mockRecipientUserInput:mockState',
recipient: 'mockRecipient:mockState',
});
});
});
describe('mapDispatchToProps()', () => {
describe('updateSendTo()', () => {
const dispatchSpy = sinon.spy();
describe('updateRecipient()', () => {
const dispatchSpy = jest.fn();
const mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy);
it('should dispatch an action', () => {
mapDispatchToPropsObject.updateSendTo('mockTo', 'mockNickname');
expect(dispatchSpy.calledOnce).toStrictEqual(true);
expect(updateSendTo).toHaveBeenCalled();
expect(updateSendTo).toHaveBeenCalledWith('mockTo', 'mockNickname');
mapDispatchToPropsObject.updateRecipient({
address: 'mockAddress',
nickname: 'mockNickname',
});
expect(dispatchSpy).toHaveBeenCalledTimes(1);
expect(dispatchSpy.mock.calls[0][0]).toStrictEqual(
'{mockUpdateRecipient: {address: mockAddress, nickname: mockNickname}}',
);
});
});
});

View File

@ -1,56 +0,0 @@
import contractMap from '@metamask/contract-metadata';
import { isConfusing } from 'unicode-confusables';
import {
REQUIRED_ERROR,
INVALID_RECIPIENT_ADDRESS_ERROR,
KNOWN_RECIPIENT_ADDRESS_ERROR,
INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR,
CONFUSING_ENS_ERROR,
CONTRACT_ADDRESS_ERROR,
} from '../../send.constants';
import {
checkExistingAddresses,
isValidDomainName,
isOriginContractAddress,
isDefaultMetaMaskChain,
} from '../../../../helpers/utils/util';
import {
isBurnAddress,
isValidHexAddress,
toChecksumHexAddress,
} from '../../../../../shared/modules/hexstring-utils';
export function getToErrorObject(to, sendTokenAddress, chainId) {
let toError = null;
if (!to) {
toError = REQUIRED_ERROR;
} else if (
isBurnAddress(to) ||
(!isValidHexAddress(to, { mixedCaseUseChecksum: true }) &&
!isValidDomainName(to))
) {
toError = isDefaultMetaMaskChain(chainId)
? INVALID_RECIPIENT_ADDRESS_ERROR
: INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR;
} else if (isOriginContractAddress(to, sendTokenAddress)) {
toError = CONTRACT_ADDRESS_ERROR;
}
return { to: toError };
}
export function getToWarningObject(to, tokens = [], sendToken = null) {
let toWarning = null;
if (
sendToken &&
(toChecksumHexAddress(to) in contractMap ||
checkExistingAddresses(to, tokens))
) {
toWarning = KNOWN_RECIPIENT_ADDRESS_ERROR;
} else if (isValidDomainName(to) && isConfusing(to)) {
toWarning = CONFUSING_ENS_ERROR;
}
return { to: toWarning };
}

View File

@ -1,115 +0,0 @@
import {
REQUIRED_ERROR,
INVALID_RECIPIENT_ADDRESS_ERROR,
KNOWN_RECIPIENT_ADDRESS_ERROR,
CONFUSING_ENS_ERROR,
CONTRACT_ADDRESS_ERROR,
} from '../../send.constants';
import { getToErrorObject, getToWarningObject } from './add-recipient';
jest.mock('../../../../helpers/utils/util', () => ({
isDefaultMetaMaskChain: jest.fn().mockReturnValue(true),
isEthNetwork: jest.fn().mockReturnValue(true),
checkExistingAddresses: jest.fn().mockReturnValue(true),
isValidDomainName: jest.requireActual('../../../../helpers/utils/util')
.isValidDomainName,
isOriginContractAddress: jest.requireActual('../../../../helpers/utils/util')
.isOriginContractAddress,
}));
jest.mock('../../../../../shared/modules/hexstring-utils', () => ({
isValidHexAddress: jest.fn((to) =>
Boolean(to.match(/^[0xabcdef123456798]+$/u)),
),
isBurnAddress: jest.fn(() => false),
toChecksumHexAddress: jest.fn((input) => input),
}));
describe('add-recipient utils', () => {
describe('getToErrorObject()', () => {
it('should return a required error if "to" is falsy', () => {
expect(getToErrorObject(null)).toStrictEqual({
to: REQUIRED_ERROR,
});
});
it('should return an invalid recipient error if "to" is truthy but invalid', () => {
expect(getToErrorObject('mockInvalidTo')).toStrictEqual({
to: INVALID_RECIPIENT_ADDRESS_ERROR,
});
});
it('should return null if "to" is truthy and valid', () => {
expect(getToErrorObject('0xabc123')).toStrictEqual({
to: null,
});
});
it('should return a contract address error if the recipient is the same as the tokens contract address', () => {
expect(getToErrorObject('0xabc123', '0xabc123')).toStrictEqual({
to: CONTRACT_ADDRESS_ERROR,
});
});
it('should return null if the recipient address is not the token contract address', () => {
expect(getToErrorObject('0xabc123', '0xabc456')).toStrictEqual({
to: null,
});
});
});
describe('getToWarningObject()', () => {
it('should return a known address recipient error if "to" is a token address', () => {
expect(
getToWarningObject('0xabc123', [{ address: '0xabc123' }], {
address: '0xabc123',
}),
).toStrictEqual({
to: KNOWN_RECIPIENT_ADDRESS_ERROR,
});
});
it('should null if "to" is a token address but sendToken is falsy', () => {
expect(
getToWarningObject('0xabc123', [{ address: '0xabc123' }]),
).toStrictEqual({
to: null,
});
});
it('should return a known address recipient error if "to" is part of contract metadata', () => {
expect(
getToWarningObject(
'0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359',
[{ address: '0xabc123' }],
{ address: '0xabc123' },
),
).toStrictEqual({
to: KNOWN_RECIPIENT_ADDRESS_ERROR,
});
});
it('should null if "to" is part of contract metadata but sendToken is falsy', () => {
expect(
getToWarningObject(
'0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359',
[{ address: '0xabc123' }],
{ address: '0xabc123' },
),
).toStrictEqual({
to: KNOWN_RECIPIENT_ADDRESS_ERROR,
});
});
it('should warn if name is a valid domain and confusable', () => {
expect(getToWarningObject('demo.eth')).toStrictEqual({
to: CONFUSING_ENS_ERROR,
});
});
it('should not warn if name is a valid domain and not confusable', () => {
expect(getToWarningObject('vitalik.eth')).toStrictEqual({
to: null,
});
});
});
});

View File

@ -2,145 +2,38 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { debounce } from 'lodash';
import copyToClipboard from 'copy-to-clipboard/index';
import ENS from 'ethjs-ens';
import networkMap from 'ethereum-ens-network-map';
import log from 'loglevel';
import { isHexString } from 'ethereumjs-util';
import { ellipsify } from '../../send.utils';
import { isValidDomainName } from '../../../../helpers/utils/util';
import { MAINNET_NETWORK_ID } from '../../../../../shared/constants/network';
import {
isBurnAddress,
isValidHexAddress,
} from '../../../../../shared/modules/hexstring-utils';
// Local Constants
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
const ZERO_X_ERROR_ADDRESS = '0x';
export default class EnsInput extends Component {
static contextTypes = {
t: PropTypes.func,
metricsEvent: PropTypes.func,
};
static propTypes = {
className: PropTypes.string,
network: PropTypes.string,
selectedAddress: PropTypes.string,
selectedName: PropTypes.string,
onChange: PropTypes.func,
updateEnsResolution: PropTypes.func,
scanQrCode: PropTypes.func,
updateEnsResolutionError: PropTypes.func,
onPaste: PropTypes.func,
onReset: PropTypes.func,
onValidAddressTyped: PropTypes.func,
contact: PropTypes.object,
value: PropTypes.string,
internalSearch: PropTypes.bool,
};
state = {
input: '',
toError: null,
ensResolution: undefined,
userInput: PropTypes.string,
onChange: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
lookupEnsName: PropTypes.func.isRequired,
initializeEnsSlice: PropTypes.func.isRequired,
resetEnsResolution: PropTypes.func.isRequired,
};
componentDidMount() {
const { network, internalSearch } = this.props;
const networkHasEnsSupport = getNetworkEnsSupport(network);
this.setState({ ensResolution: ZERO_ADDRESS });
if (networkHasEnsSupport && !internalSearch) {
const provider = global.ethereumProvider;
this.ens = new ENS({ provider, network });
this.checkName = debounce(this.lookupEnsName, 200);
this.props.initializeEnsSlice();
}
}
componentDidUpdate(prevProps) {
const { input } = this.state;
const { network, value, internalSearch } = this.props;
let newValue;
// Set the value of our input based on QR code provided by parent
const newProvidedValue = input !== value && prevProps.value !== value;
if (newProvidedValue) {
newValue = value;
}
if (prevProps.network !== network) {
if (getNetworkEnsSupport(network)) {
const provider = global.ethereumProvider;
this.ens = new ENS({ provider, network });
this.checkName = debounce(this.lookupEnsName, 200);
if (!newProvidedValue) {
newValue = input;
}
} else {
// ens is null on mount on a network that does not have ens support
// this is intended to prevent accidental lookup of domains across
// networks
this.ens = null;
this.checkName = null;
}
}
if (newValue !== undefined) {
this.onChange({ target: { value: newValue } });
}
if (!internalSearch && prevProps.internalSearch) {
this.resetInput();
}
}
resetInput = () => {
const {
updateEnsResolution,
updateEnsResolutionError,
onReset,
} = this.props;
this.onChange({ target: { value: '' } });
onReset();
updateEnsResolution('');
updateEnsResolutionError('');
};
lookupEnsName = (ensName) => {
const { network } = this.props;
const recipient = ensName.trim();
log.info(`ENS attempting to resolve name: ${recipient}`);
this.ens
.lookup(recipient)
.then((address) => {
if (address === ZERO_ADDRESS) {
throw new Error(this.context.t('noAddressForName'));
}
if (address === ZERO_X_ERROR_ADDRESS) {
throw new Error(this.context.t('ensRegistrationError'));
}
this.props.updateEnsResolution(address);
})
.catch((reason) => {
if (
isValidDomainName(recipient) &&
reason.message === 'ENS name not defined.'
) {
this.props.updateEnsResolutionError(
network === MAINNET_NETWORK_ID
? this.context.t('noAddressForName')
: this.context.t('ensNotFoundOnCurrentNetwork'),
);
} else {
log.error(reason);
this.props.updateEnsResolutionError(reason.message);
}
});
};
onPaste = (event) => {
event.clipboardData.items[0].getAsString((text) => {
@ -155,40 +48,23 @@ export default class EnsInput extends Component {
onChange = (e) => {
const {
network,
onChange,
updateEnsResolution,
updateEnsResolutionError,
onValidAddressTyped,
internalSearch,
onChange,
lookupEnsName,
resetEnsResolution,
} = this.props;
const input = e.target.value;
const networkHasEnsSupport = getNetworkEnsSupport(network);
this.setState({ input }, () => onChange(input));
onChange(input);
if (internalSearch) {
return null;
}
// Empty ENS state if input is empty
// maybe scan ENS
if (
!networkHasEnsSupport &&
!(
isBurnAddress(input) === false &&
isValidHexAddress(input, { mixedCaseUseChecksum: true })
) &&
!isHexString(input)
) {
updateEnsResolution('');
updateEnsResolutionError(
networkHasEnsSupport ? '' : 'Network does not support ENS',
);
return null;
}
if (isValidDomainName(input)) {
this.lookupEnsName(input);
lookupEnsName(input);
} else if (
onValidAddressTyped &&
!isBurnAddress(input) &&
@ -196,20 +72,16 @@ export default class EnsInput extends Component {
) {
onValidAddressTyped(input);
} else {
updateEnsResolution('');
updateEnsResolutionError('');
resetEnsResolution();
}
return null;
};
render() {
const { t } = this.context;
const { className, selectedAddress } = this.props;
const { input } = this.state;
const { className, selectedAddress, selectedName, userInput } = this.props;
if (selectedAddress) {
return this.renderSelected();
}
const hasSelectedAddress = Boolean(selectedAddress);
return (
<div className={classnames('ens-input', className)}>
@ -217,61 +89,21 @@ export default class EnsInput extends Component {
className={classnames('ens-input__wrapper', {
'ens-input__wrapper__status-icon--error': false,
'ens-input__wrapper__status-icon--valid': false,
'ens-input__wrapper--valid': hasSelectedAddress,
})}
>
<div className="ens-input__wrapper__status-icon" />
<input
className="ens-input__wrapper__input"
type="text"
dir="auto"
placeholder={t('recipientAddressPlaceholder')}
onChange={this.onChange}
onPaste={this.onPaste}
value={selectedAddress || input}
autoFocus
data-testid="ens-input"
/>
<button
className={classnames('ens-input__wrapper__action-icon', {
'ens-input__wrapper__action-icon--erase': input,
'ens-input__wrapper__action-icon--qrcode': !input,
})}
onClick={() => {
if (input) {
this.resetInput();
} else {
this.props.scanQrCode();
}
}}
/>
</div>
</div>
);
}
renderSelected() {
const { t } = this.context;
const {
className,
selectedAddress,
selectedName,
contact = {},
} = this.props;
const name = contact.name || selectedName;
return (
<div className={classnames('ens-input', className)}>
<div className="ens-input__wrapper ens-input__wrapper--valid">
<div className="ens-input__wrapper__status-icon ens-input__wrapper__status-icon--valid" />
<div
className="ens-input__wrapper__input ens-input__wrapper__input--selected"
placeholder={t('recipientAddress')}
onChange={this.onChange}
>
className={classnames('ens-input__wrapper__status-icon', {
'ens-input__wrapper__status-icon--valid': hasSelectedAddress,
})}
/>
{hasSelectedAddress ? (
<>
<div className="ens-input__wrapper__input ens-input__wrapper__input--selected">
<div className="ens-input__selected-input__title">
{name || ellipsify(selectedAddress)}
{selectedName || ellipsify(selectedAddress)}
</div>
{name && (
{selectedName && (
<div className="ens-input__selected-input__subtitle">
{selectedAddress}
</div>
@ -279,73 +111,39 @@ export default class EnsInput extends Component {
</div>
<div
className="ens-input__wrapper__action-icon ens-input__wrapper__action-icon--erase"
onClick={this.resetInput}
onClick={this.props.onReset}
/>
</>
) : (
<>
<input
className="ens-input__wrapper__input"
type="text"
dir="auto"
placeholder={t('recipientAddressPlaceholder')}
onChange={this.onChange}
onPaste={this.onPaste}
value={selectedAddress || userInput}
autoFocus
data-testid="ens-input"
/>
<button
className={classnames('ens-input__wrapper__action-icon', {
'ens-input__wrapper__action-icon--erase': userInput,
'ens-input__wrapper__action-icon--qrcode': !userInput,
})}
onClick={() => {
if (userInput) {
this.props.onReset();
} else {
this.props.scanQrCode();
}
}}
/>
</>
)}
</div>
</div>
);
}
ensIcon(recipient) {
const { hoverText } = this.state;
return (
<span
className="#ensIcon"
title={hoverText}
style={{
position: 'absolute',
top: '16px',
left: '-25px',
}}
>
{this.ensIconContents(recipient)}
</span>
);
}
ensIconContents() {
const { loadingEns, ensFailure, ensResolution, toError } = this.state;
if (toError) {
return null;
}
if (loadingEns) {
return (
<img
src="images/loading.svg"
style={{
width: '30px',
height: '30px',
transform: 'translateY(-6px)',
}}
/>
);
}
if (ensFailure) {
return <i className="fa fa-warning fa-lg warning" />;
}
if (ensResolution && ensResolution !== ZERO_ADDRESS) {
return (
<i
className="fa fa-check-circle fa-lg cursor-pointer"
style={{ color: 'green' }}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
copyToClipboard(ensResolution);
}}
/>
);
}
return null;
}
}
function getNetworkEnsSupport(network) {
return Boolean(networkMap[network]);
}

View File

@ -1,20 +1,18 @@
import { debounce } from 'lodash';
import { connect } from 'react-redux';
import { CHAIN_ID_TO_NETWORK_ID_MAP } from '../../../../../shared/constants/network';
import {
getSendTo,
getSendToNickname,
getAddressBookEntry,
getCurrentChainId,
} from '../../../../selectors';
lookupEnsName,
initializeEnsSlice,
resetResolution,
} from '../../../../ducks/ens';
import EnsInput from './ens-input.component';
export default connect((state) => {
const selectedAddress = getSendTo(state);
const chainId = getCurrentChainId(state);
function mapDispatchToProps(dispatch) {
return {
network: CHAIN_ID_TO_NETWORK_ID_MAP[chainId],
selectedAddress,
selectedName: getSendToNickname(state),
contact: getAddressBookEntry(state, selectedAddress),
lookupEnsName: debounce((ensName) => dispatch(lookupEnsName(ensName)), 150),
initializeEnsSlice: () => dispatch(initializeEnsSlice()),
resetEnsResolution: debounce(() => dispatch(resetResolution()), 300),
};
})(EnsInput);
}
export default connect(null, mapDispatchToProps)(EnsInput);

View File

@ -1,93 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import sinon from 'sinon';
import AmountMaxButton from './amount-max-button.component';
describe('AmountMaxButton Component', () => {
let wrapper;
let instance;
const propsMethodSpies = {
setAmountToMax: sinon.spy(),
setMaxModeTo: sinon.spy(),
};
const MOCK_EVENT = { preventDefault: () => undefined };
beforeAll(() => {
sinon.spy(AmountMaxButton.prototype, 'setMaxAmount');
});
beforeEach(() => {
wrapper = shallow(
<AmountMaxButton
balance="mockBalance"
gasTotal="mockGasTotal"
maxModeOn={false}
sendToken={{ address: 'mockTokenAddress' }}
setAmountToMax={propsMethodSpies.setAmountToMax}
setMaxModeTo={propsMethodSpies.setMaxModeTo}
tokenBalance="mockTokenBalance"
/>,
{
context: {
t: (str) => `${str}_t`,
metricsEvent: () => undefined,
},
},
);
instance = wrapper.instance();
});
afterEach(() => {
propsMethodSpies.setAmountToMax.resetHistory();
propsMethodSpies.setMaxModeTo.resetHistory();
AmountMaxButton.prototype.setMaxAmount.resetHistory();
});
afterAll(() => {
sinon.restore();
});
describe('setMaxAmount', () => {
it('should call setAmountToMax with the correct params', () => {
expect(propsMethodSpies.setAmountToMax.callCount).toStrictEqual(0);
instance.setMaxAmount();
expect(propsMethodSpies.setAmountToMax.callCount).toStrictEqual(1);
expect(propsMethodSpies.setAmountToMax.getCall(0).args).toStrictEqual([
{
balance: 'mockBalance',
gasTotal: 'mockGasTotal',
sendToken: { address: 'mockTokenAddress' },
tokenBalance: 'mockTokenBalance',
},
]);
});
});
describe('render', () => {
it('should render an element with a send-v2__amount-max class', () => {
expect(wrapper.find('.send-v2__amount-max')).toHaveLength(1);
});
it('should call setMaxModeTo and setMaxAmount when the checkbox is checked', () => {
const { onClick } = wrapper.find('.send-v2__amount-max').props();
expect(AmountMaxButton.prototype.setMaxAmount.callCount).toStrictEqual(0);
expect(propsMethodSpies.setMaxModeTo.callCount).toStrictEqual(0);
onClick(MOCK_EVENT);
expect(AmountMaxButton.prototype.setMaxAmount.callCount).toStrictEqual(1);
expect(propsMethodSpies.setMaxModeTo.callCount).toStrictEqual(1);
expect(propsMethodSpies.setMaxModeTo.getCall(0).args).toStrictEqual([
true,
]);
});
it('should render the expected text when maxModeOn is false', () => {
wrapper.setProps({ maxModeOn: false });
expect(wrapper.find('.send-v2__amount-max').text()).toStrictEqual(
'max_t',
);
});
});
});

View File

@ -1,42 +0,0 @@
import { connect } from 'react-redux';
import {
getGasTotal,
getSendToken,
getSendFromBalance,
getTokenBalance,
getSendMaxModeState,
getBasicGasEstimateLoadingStatus,
} from '../../../../../selectors';
import {
updateSendErrors,
updateSendAmount,
setMaxModeTo,
} from '../../../../../ducks/send/send.duck';
import { calcMaxAmount } from './amount-max-button.utils';
import AmountMaxButton from './amount-max-button.component';
export default connect(mapStateToProps, mapDispatchToProps)(AmountMaxButton);
function mapStateToProps(state) {
return {
balance: getSendFromBalance(state),
buttonDataLoading: getBasicGasEstimateLoadingStatus(state),
gasTotal: getGasTotal(state),
maxModeOn: getSendMaxModeState(state),
sendToken: getSendToken(state),
tokenBalance: getTokenBalance(state),
};
}
function mapDispatchToProps(dispatch) {
return {
setAmountToMax: (maxAmountDataObject) => {
dispatch(updateSendErrors({ amount: null }));
dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject)));
},
clearMaxAmount: () => {
dispatch(updateSendAmount('0'));
},
setMaxModeTo: (bool) => dispatch(setMaxModeTo(bool)),
};
}

View File

@ -1,83 +0,0 @@
import sinon from 'sinon';
import {
updateSendErrors,
setMaxModeTo,
updateSendAmount,
} from '../../../../../ducks/send/send.duck';
let mapStateToProps;
let mapDispatchToProps;
jest.mock('react-redux', () => ({
connect: (ms, md) => {
mapStateToProps = ms;
mapDispatchToProps = md;
return () => ({});
},
}));
jest.mock('../../../../../selectors', () => ({
getGasTotal: (s) => `mockGasTotal:${s}`,
getSendToken: (s) => `mockSendToken:${s}`,
getSendFromBalance: (s) => `mockBalance:${s}`,
getTokenBalance: (s) => `mockTokenBalance:${s}`,
getSendMaxModeState: (s) => `mockMaxModeOn:${s}`,
getBasicGasEstimateLoadingStatus: (s) => `mockButtonDataLoading:${s}`,
}));
jest.mock('./amount-max-button.utils.js', () => ({
calcMaxAmount: (mockObj) => mockObj.val + 1,
}));
jest.mock('../../../../../ducks/send/send.duck', () => ({
setMaxModeTo: jest.fn(),
updateSendAmount: jest.fn(),
updateSendErrors: jest.fn(),
}));
require('./amount-max-button.container.js');
describe('amount-max-button container', () => {
describe('mapStateToProps()', () => {
it('should map the correct properties to props', () => {
expect(mapStateToProps('mockState')).toStrictEqual({
balance: 'mockBalance:mockState',
buttonDataLoading: 'mockButtonDataLoading:mockState',
gasTotal: 'mockGasTotal:mockState',
maxModeOn: 'mockMaxModeOn:mockState',
sendToken: 'mockSendToken:mockState',
tokenBalance: 'mockTokenBalance:mockState',
});
});
});
describe('mapDispatchToProps()', () => {
let dispatchSpy;
let mapDispatchToPropsObject;
beforeEach(() => {
dispatchSpy = sinon.spy();
mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy);
});
describe('setAmountToMax()', () => {
it('should dispatch an action', () => {
mapDispatchToPropsObject.setAmountToMax({ val: 11, foo: 'bar' });
expect(dispatchSpy.calledTwice).toStrictEqual(true);
expect(updateSendErrors).toHaveBeenCalled();
expect(updateSendErrors).toHaveBeenCalledWith({ amount: null });
expect(updateSendAmount).toHaveBeenCalled();
expect(updateSendAmount).toHaveBeenCalledWith(12);
});
});
describe('setMaxModeTo()', () => {
it('should dispatch an action', () => {
mapDispatchToPropsObject.setMaxModeTo('mockVal');
expect(dispatchSpy.calledOnce).toStrictEqual(true);
expect(setMaxModeTo).toHaveBeenCalledWith('mockVal');
});
});
});
});

View File

@ -0,0 +1,49 @@
import React from 'react';
import classnames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { getBasicGasEstimateLoadingStatus } from '../../../../../selectors';
import {
getSendMaxModeState,
isSendFormInvalid,
toggleSendMaxMode,
} from '../../../../../ducks/send';
import { useI18nContext } from '../../../../../hooks/useI18nContext';
import { useMetricEvent } from '../../../../../hooks/useMetricEvent';
export default function AmountMaxButton() {
const buttonDataLoading = useSelector(getBasicGasEstimateLoadingStatus);
const isDraftTransactionInvalid = useSelector(isSendFormInvalid);
const maxModeOn = useSelector(getSendMaxModeState);
const dispatch = useDispatch();
const trackClickedMax = useMetricEvent({
eventOpts: {
category: 'Transactions',
action: 'Edit Screen',
name: 'Clicked "Amount Max"',
},
});
const t = useI18nContext();
const onMaxClick = () => {
trackClickedMax();
dispatch(toggleSendMaxMode());
};
return (
<button
className="send-v2__amount-max"
disabled={buttonDataLoading || isDraftTransactionInvalid}
onClick={onMaxClick}
>
<input type="checkbox" checked={maxModeOn} readOnly />
<div
className={classnames('send-v2__amount-max__button', {
'send-v2__amount-max__button__disabled':
buttonDataLoading || isDraftTransactionInvalid,
})}
>
{t('max')}
</div>
</button>
);
}

View File

@ -0,0 +1,61 @@
import React from 'react';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fireEvent } from '@testing-library/react';
import { initialState, SEND_STATUSES } from '../../../../../ducks/send';
import { renderWithProvider } from '../../../../../../test/jest';
import AmountMaxButton from './amount-max-button';
const middleware = [thunk];
describe('AmountMaxButton Component', () => {
describe('render', () => {
it('should render a "Max" button', () => {
const { getByText } = renderWithProvider(
<AmountMaxButton />,
configureMockStore(middleware)({
send: initialState,
gas: { basicEstimateStatus: 'LOADING' },
}),
);
expect(getByText('Max')).toBeTruthy();
});
it('should dispatch action to set mode to MAX', () => {
const store = configureMockStore(middleware)({
send: { ...initialState, status: SEND_STATUSES.VALID },
gas: { basicEstimateStatus: 'READY' },
});
const { getByText } = renderWithProvider(<AmountMaxButton />, store);
const expectedActions = [
{ type: 'send/updateAmountMode', payload: 'MAX' },
];
fireEvent.click(getByText('Max'), { bubbles: true });
const actions = store.getActions();
expect(actions).toStrictEqual(expectedActions);
});
it('should dispatch action to set amount mode to INPUT', () => {
const store = configureMockStore(middleware)({
send: {
...initialState,
status: SEND_STATUSES.VALID,
amount: { ...initialState.amount, mode: 'MAX' },
},
gas: { basicEstimateStatus: 'READY' },
});
const { getByText } = renderWithProvider(<AmountMaxButton />, store);
const expectedActions = [
{ type: 'send/updateAmountMode', payload: 'INPUT' },
];
fireEvent.click(getByText('Max'), { bubbles: true });
const actions = store.getActions();
expect(actions).toStrictEqual(expectedActions);
});
});
});

View File

@ -1,22 +0,0 @@
import {
multiplyCurrencies,
subtractCurrencies,
} from '../../../../../helpers/utils/conversion-util';
import { addHexPrefix } from '../../../../../../app/scripts/lib/util';
export function calcMaxAmount({ balance, gasTotal, sendToken, tokenBalance }) {
const { decimals } = sendToken || {};
const multiplier = Math.pow(10, Number(decimals || 0));
return sendToken
? multiplyCurrencies(tokenBalance, multiplier, {
toNumericBase: 'hex',
multiplicandBase: 16,
multiplierBase: 10,
})
: subtractCurrencies(addHexPrefix(balance), addHexPrefix(gasTotal), {
toNumericBase: 'hex',
aBase: 16,
bBase: 16,
});
}

View File

@ -1,26 +0,0 @@
import { calcMaxAmount } from './amount-max-button.utils';
describe('amount-max-button utils', () => {
describe('calcMaxAmount()', () => {
it('should calculate the correct amount when no sendToken defined', () => {
expect(
calcMaxAmount({
balance: 'ffffff',
gasTotal: 'ff',
sendToken: false,
}),
).toStrictEqual('ffff00');
});
it('should calculate the correct amount when a sendToken is defined', () => {
expect(
calcMaxAmount({
sendToken: {
decimals: 10,
},
tokenBalance: '64',
}),
).toStrictEqual('e8d4a51000');
});
});
});

View File

@ -1 +1 @@
export { default } from './amount-max-button.container';
export { default } from './amount-max-button';

View File

@ -1,111 +1,35 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { debounce } from 'lodash';
import SendRowWrapper from '../send-row-wrapper';
import UserPreferencedCurrencyInput from '../../../../components/app/user-preferenced-currency-input';
import UserPreferencedTokenInput from '../../../../components/app/user-preferenced-token-input';
import { ASSET_TYPES } from '../../../../ducks/send';
import AmountMaxButton from './amount-max-button';
export default class SendAmountRow extends Component {
static propTypes = {
amount: PropTypes.string,
balance: PropTypes.string,
conversionRate: PropTypes.number,
gasTotal: PropTypes.string,
inError: PropTypes.bool,
primaryCurrency: PropTypes.string,
sendToken: PropTypes.object,
setMaxModeTo: PropTypes.func,
tokenBalance: PropTypes.string,
updateGasFeeError: PropTypes.func,
asset: PropTypes.object,
updateSendAmount: PropTypes.func,
updateSendAmountError: PropTypes.func,
updateGas: PropTypes.func,
maxModeOn: PropTypes.bool,
};
static contextTypes = {
t: PropTypes.func,
};
componentDidUpdate(prevProps) {
const { maxModeOn: prevMaxModeOn, gasTotal: prevGasTotal } = prevProps;
const { maxModeOn, amount, gasTotal, sendToken } = this.props;
if (maxModeOn && sendToken && !prevMaxModeOn) {
this.updateGas(amount);
}
if (prevGasTotal !== gasTotal) {
this.validateAmount(amount);
}
}
updateGas = debounce(this.updateGas.bind(this), 500);
validateAmount(amount) {
const {
balance,
conversionRate,
gasTotal,
primaryCurrency,
sendToken,
tokenBalance,
updateGasFeeError,
updateSendAmountError,
} = this.props;
updateSendAmountError({
amount,
balance,
conversionRate,
gasTotal,
primaryCurrency,
sendToken,
tokenBalance,
});
if (sendToken) {
updateGasFeeError({
balance,
conversionRate,
gasTotal,
primaryCurrency,
sendToken,
tokenBalance,
});
}
}
updateAmount(amount) {
const { updateSendAmount, setMaxModeTo } = this.props;
setMaxModeTo(false);
updateSendAmount(amount);
}
updateGas(amount) {
const { sendToken, updateGas } = this.props;
if (sendToken) {
updateGas({ amount });
}
}
handleChange = (newAmount) => {
this.validateAmount(newAmount);
this.updateGas(newAmount);
this.updateAmount(newAmount);
this.props.updateSendAmount(newAmount);
};
renderInput() {
const { amount, inError, sendToken } = this.props;
const { amount, inError, asset } = this.props;
return sendToken ? (
return asset.type === ASSET_TYPES.TOKEN ? (
<UserPreferencedTokenInput
error={inError}
onChange={this.handleChange}
token={sendToken}
token={asset.details}
value={amount}
/>
) : (
@ -118,7 +42,7 @@ export default class SendAmountRow extends Component {
}
render() {
const { gasTotal, inError } = this.props;
const { inError } = this.props;
return (
<SendRowWrapper
@ -126,7 +50,7 @@ export default class SendAmountRow extends Component {
showError={inError}
errorType="amount"
>
{gasTotal && <AmountMaxButton inError={inError} />}
<AmountMaxButton inError={inError} />
{this.renderInput()}
</SendRowWrapper>
);

View File

@ -3,88 +3,13 @@ import { shallow } from 'enzyme';
import sinon from 'sinon';
import SendRowWrapper from '../send-row-wrapper/send-row-wrapper.component';
import UserPreferencedTokenInput from '../../../../components/app/user-preferenced-token-input';
import { ASSET_TYPES } from '../../../../ducks/send';
import SendAmountRow from './send-amount-row.component';
import AmountMaxButton from './amount-max-button/amount-max-button.container';
import AmountMaxButton from './amount-max-button/amount-max-button';
describe('SendAmountRow Component', () => {
describe('validateAmount', () => {
it('should call updateSendAmountError with the correct params', () => {
const {
instance,
propsMethodSpies: { updateSendAmountError },
} = shallowRenderSendAmountRow();
expect(updateSendAmountError.callCount).toStrictEqual(0);
instance.validateAmount('someAmount');
expect(
updateSendAmountError.calledOnceWithExactly({
amount: 'someAmount',
balance: 'mockBalance',
conversionRate: 7,
gasTotal: 'mockGasTotal',
primaryCurrency: 'mockPrimaryCurrency',
sendToken: { address: 'mockTokenAddress' },
tokenBalance: 'mockTokenBalance',
}),
).toStrictEqual(true);
});
it('should call updateGasFeeError if sendToken is truthy', () => {
const {
instance,
propsMethodSpies: { updateGasFeeError },
} = shallowRenderSendAmountRow();
expect(updateGasFeeError.callCount).toStrictEqual(0);
instance.validateAmount('someAmount');
expect(
updateGasFeeError.calledOnceWithExactly({
balance: 'mockBalance',
conversionRate: 7,
gasTotal: 'mockGasTotal',
primaryCurrency: 'mockPrimaryCurrency',
sendToken: { address: 'mockTokenAddress' },
tokenBalance: 'mockTokenBalance',
}),
).toStrictEqual(true);
});
it('should call not updateGasFeeError if sendToken is falsey', () => {
const {
wrapper,
instance,
propsMethodSpies: { updateGasFeeError },
} = shallowRenderSendAmountRow();
wrapper.setProps({ sendToken: null });
expect(updateGasFeeError.callCount).toStrictEqual(0);
instance.validateAmount('someAmount');
expect(updateGasFeeError.callCount).toStrictEqual(0);
});
});
describe('updateAmount', () => {
it('should call setMaxModeTo', () => {
const {
instance,
propsMethodSpies: { setMaxModeTo },
} = shallowRenderSendAmountRow();
expect(setMaxModeTo.callCount).toStrictEqual(0);
instance.updateAmount('someAmount');
expect(setMaxModeTo.calledOnceWithExactly(false)).toStrictEqual(true);
});
it('should call updateSendAmount', () => {
const {
instance,
@ -93,7 +18,7 @@ describe('SendAmountRow Component', () => {
expect(updateSendAmount.callCount).toStrictEqual(0);
instance.updateAmount('someAmount');
instance.handleChange('someAmount');
expect(
updateSendAmount.calledOnceWithExactly('someAmount'),
@ -136,10 +61,7 @@ describe('SendAmountRow Component', () => {
});
it('should render the UserPreferencedTokenInput with the correct props', () => {
const {
wrapper,
instanceSpies: { updateGas, updateAmount, validateAmount },
} = shallowRenderSendAmountRow();
const { wrapper } = shallowRenderSendAmountRow();
const { onChange, error, value } = wrapper
.find(SendRowWrapper)
.childAt(1)
@ -147,67 +69,34 @@ describe('SendAmountRow Component', () => {
expect(error).toStrictEqual(false);
expect(value).toStrictEqual('mockAmount');
expect(updateGas.callCount).toStrictEqual(0);
expect(updateAmount.callCount).toStrictEqual(0);
expect(validateAmount.callCount).toStrictEqual(0);
onChange('mockNewAmount');
expect(updateGas.calledOnceWithExactly('mockNewAmount')).toStrictEqual(
true,
);
expect(updateAmount.calledOnceWithExactly('mockNewAmount')).toStrictEqual(
true,
);
expect(
validateAmount.calledOnceWithExactly('mockNewAmount'),
).toStrictEqual(true);
});
});
});
function shallowRenderSendAmountRow() {
const setMaxModeTo = sinon.spy();
const updateGasFeeError = sinon.spy();
const updateSendAmount = sinon.spy();
const updateSendAmountError = sinon.spy();
const wrapper = shallow(
<SendAmountRow
amount="mockAmount"
balance="mockBalance"
conversionRate={7}
convertedCurrency="mockConvertedCurrency"
gasTotal="mockGasTotal"
inError={false}
primaryCurrency="mockPrimaryCurrency"
sendToken={{ address: 'mockTokenAddress' }}
setMaxModeTo={setMaxModeTo}
tokenBalance="mockTokenBalance"
updateGasFeeError={updateGasFeeError}
asset={{
type: ASSET_TYPES.TOKEN,
balance: 'mockTokenBalance',
details: { address: 'mockTokenAddress' },
}}
updateSendAmount={updateSendAmount}
updateSendAmountError={updateSendAmountError}
updateGas={() => undefined}
/>,
{ context: { t: (str) => `${str}_t` } },
);
const instance = wrapper.instance();
const updateAmount = sinon.spy(instance, 'updateAmount');
const updateGas = sinon.spy(instance, 'updateGas');
const validateAmount = sinon.spy(instance, 'validateAmount');
return {
instance,
wrapper,
propsMethodSpies: {
setMaxModeTo,
updateGasFeeError,
updateSendAmount,
updateSendAmountError,
},
instanceSpies: {
updateAmount,
updateGas,
validateAmount,
},
};
}

View File

@ -1,21 +1,10 @@
import { connect } from 'react-redux';
import {
getGasTotal,
getPrimaryCurrency,
getSendToken,
getSendAmount,
getSendFromBalance,
getTokenBalance,
getSendMaxModeState,
sendAmountIsInError,
} from '../../../../selectors';
import { getAmountErrorObject, getGasFeeErrorObject } from '../../send.utils';
import {
updateSendErrors,
setMaxModeTo,
updateSendAmount,
} from '../../../../ducks/send/send.duck';
import { getConversionRate } from '../../../../ducks/metamask/metamask';
getSendAmount,
sendAmountIsInError,
getSendAsset,
} from '../../../../ducks/send';
import SendAmountRow from './send-amount-row.component';
export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow);
@ -23,26 +12,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow);
function mapStateToProps(state) {
return {
amount: getSendAmount(state),
balance: getSendFromBalance(state),
conversionRate: getConversionRate(state),
gasTotal: getGasTotal(state),
inError: sendAmountIsInError(state),
primaryCurrency: getPrimaryCurrency(state),
sendToken: getSendToken(state),
tokenBalance: getTokenBalance(state),
maxModeOn: getSendMaxModeState(state),
asset: getSendAsset(state),
};
}
function mapDispatchToProps(dispatch) {
return {
setMaxModeTo: (bool) => dispatch(setMaxModeTo(bool)),
updateSendAmount: (newAmount) => dispatch(updateSendAmount(newAmount)),
updateGasFeeError: (amountDataObject) => {
dispatch(updateSendErrors(getGasFeeErrorObject(amountDataObject)));
},
updateSendAmountError: (amountDataObject) => {
dispatch(updateSendErrors(getAmountErrorObject(amountDataObject)));
},
};
}

View File

@ -1,10 +1,6 @@
import sinon from 'sinon';
import {
updateSendErrors,
setMaxModeTo,
updateSendAmount,
} from '../../../../ducks/send/send.duck';
import { updateSendAmount } from '../../../../ducks/send';
let mapDispatchToProps;
@ -15,24 +11,7 @@ jest.mock('react-redux', () => ({
},
}));
jest.mock('../../../../selectors/send.js', () => ({
sendAmountIsInError: (s) => `mockInError:${s}`,
}));
jest.mock('../../send.utils', () => ({
getAmountErrorObject: (mockDataObject) => ({
...mockDataObject,
mockChange: true,
}),
getGasFeeErrorObject: (mockDataObject) => ({
...mockDataObject,
mockGasFeeErrorChange: true,
}),
}));
jest.mock('../../../../ducks/send/send.duck', () => ({
updateSendErrors: jest.fn(),
setMaxModeTo: jest.fn(),
jest.mock('../../../../ducks/send', () => ({
updateSendAmount: jest.fn(),
}));
@ -48,15 +27,6 @@ describe('send-amount-row container', () => {
mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy);
});
describe('setMaxModeTo()', () => {
it('should dispatch an action', () => {
mapDispatchToPropsObject.setMaxModeTo('mockBool');
expect(dispatchSpy.calledOnce).toStrictEqual(true);
expect(setMaxModeTo).toHaveBeenCalled();
expect(setMaxModeTo).toHaveBeenCalledWith('mockBool');
});
});
describe('updateSendAmount()', () => {
it('should dispatch an action', () => {
mapDispatchToPropsObject.updateSendAmount('mockAmount');
@ -65,29 +35,5 @@ describe('send-amount-row container', () => {
expect(updateSendAmount).toHaveBeenCalledWith('mockAmount');
});
});
describe('updateGasFeeError()', () => {
it('should dispatch an action', () => {
mapDispatchToPropsObject.updateGasFeeError({ some: 'data' });
expect(dispatchSpy.calledOnce).toStrictEqual(true);
expect(updateSendErrors).toHaveBeenCalled();
expect(updateSendErrors).toHaveBeenCalledWith({
some: 'data',
mockGasFeeErrorChange: true,
});
});
});
describe('updateSendAmountError()', () => {
it('should dispatch an action', () => {
mapDispatchToPropsObject.updateSendAmountError({ some: 'data' });
expect(dispatchSpy.calledOnce).toStrictEqual(true);
expect(updateSendErrors).toHaveBeenCalled();
expect(updateSendErrors).toHaveBeenCalledWith({
some: 'data',
mockChange: true,
});
});
});
});
});

View File

@ -5,6 +5,7 @@ import Identicon from '../../../../components/ui/identicon/identicon.component';
import TokenBalance from '../../../../components/ui/token-balance';
import UserPreferencedCurrencyDisplay from '../../../../components/app/user-preferenced-currency-display';
import { ERC20, PRIMARY } from '../../../../helpers/constants/common';
import { ASSET_TYPES } from '../../../../ducks/send';
export default class SendAssetRow extends Component {
static propTypes = {
@ -18,13 +19,10 @@ export default class SendAssetRow extends Component {
accounts: PropTypes.object.isRequired,
assetImages: PropTypes.object,
selectedAddress: PropTypes.string.isRequired,
sendTokenAddress: PropTypes.string,
setSendToken: PropTypes.func.isRequired,
sendAssetAddress: PropTypes.string,
updateSendAsset: PropTypes.func.isRequired,
nativeCurrency: PropTypes.string,
nativeCurrencyImage: PropTypes.string,
setUnsendableAssetError: PropTypes.func.isRequired,
updateSendErrors: PropTypes.func.isRequired,
updateTokenType: PropTypes.func.isRequired,
};
static contextTypes = {
@ -46,29 +44,7 @@ export default class SendAssetRow extends Component {
closeDropdown = () => this.setState({ isShowingDropdown: false });
clearUnsendableAssetError = () => {
this.props.setUnsendableAssetError(false);
this.props.updateSendErrors({
unsendableAssetError: null,
gasLoadingError: null,
});
};
selectToken = async (token) => {
if (token && token.isERC721 === undefined) {
const updatedToken = await this.props.updateTokenType(token.address);
if (updatedToken.isERC721) {
this.props.setUnsendableAssetError(true);
this.props.updateSendErrors({
unsendableAssetError: 'unsendableAssetError',
});
}
}
if ((token && token.isERC721 === false) || token === undefined) {
this.clearUnsendableAssetError();
}
selectToken = (type, token) => {
this.setState(
{
isShowingDropdown: false,
@ -84,7 +60,10 @@ export default class SendAssetRow extends Component {
assetSelected: token ? ERC20 : this.props.nativeCurrency,
},
});
this.props.setSendToken(token);
this.props.updateSendAsset({
type,
details: type === ASSET_TYPES.NATIVE ? null : token,
});
},
);
};
@ -105,9 +84,9 @@ export default class SendAssetRow extends Component {
}
renderSendToken() {
const { sendTokenAddress } = this.props;
const { sendAssetAddress } = this.props;
const token = this.props.tokens.find(
({ address }) => address === sendTokenAddress,
({ address }) => address === sendAssetAddress,
);
return (
<div
@ -158,7 +137,7 @@ export default class SendAssetRow extends Component {
? 'send-v2__asset-dropdown__asset'
: 'send-v2__asset-dropdown__single-asset'
}
onClick={() => this.selectToken()}
onClick={() => this.selectToken(ASSET_TYPES.NATIVE)}
>
<div className="send-v2__asset-dropdown__asset-icon">
<Identicon
@ -197,7 +176,7 @@ export default class SendAssetRow extends Component {
<div
key={address}
className="send-v2__asset-dropdown__asset"
onClick={() => this.selectToken(token)}
onClick={() => this.selectToken(ASSET_TYPES.TOKEN, token)}
>
<div className="send-v2__asset-dropdown__asset-icon">
<Identicon

View File

@ -3,21 +3,16 @@ import { getNativeCurrency } from '../../../../ducks/metamask/metamask';
import {
getMetaMaskAccounts,
getNativeCurrencyImage,
getSendTokenAddress,
getAssetImages,
} from '../../../../selectors';
import { updateTokenType } from '../../../../store/actions';
import {
updateSendErrors,
updateSendToken,
} from '../../../../ducks/send/send.duck';
import { updateSendAsset, getSendAssetAddress } from '../../../../ducks/send';
import SendAssetRow from './send-asset-row.component';
function mapStateToProps(state) {
return {
tokens: state.metamask.tokens,
selectedAddress: state.metamask.selectedAddress,
sendTokenAddress: getSendTokenAddress(state),
sendAssetAddress: getSendAssetAddress(state),
accounts: getMetaMaskAccounts(state),
nativeCurrency: getNativeCurrency(state),
nativeCurrencyImage: getNativeCurrencyImage(state),
@ -27,11 +22,8 @@ function mapStateToProps(state) {
function mapDispatchToProps(dispatch) {
return {
setSendToken: (token) => dispatch(updateSendToken(token)),
updateTokenType: (tokenAddress) => dispatch(updateTokenType(tokenAddress)),
updateSendErrors: (error) => {
dispatch(updateSendErrors(error));
},
updateSendAsset: ({ type, details }) =>
dispatch(updateSendAsset({ type, details })),
};
}

View File

@ -18,12 +18,8 @@ export default class SendContent extends Component {
t: PropTypes.func,
};
state = {
unsendableAssetError: false,
};
static propTypes = {
updateGas: PropTypes.func,
isAssetSendable: PropTypes.bool,
showAddToAddressBookModal: PropTypes.func,
showHexData: PropTypes.bool,
contact: PropTypes.object,
@ -35,11 +31,6 @@ export default class SendContent extends Component {
noGasPrice: PropTypes.bool,
};
updateGas = (updateData) => this.props.updateGas(updateData);
setUnsendableAssetError = (unsendableAssetError) =>
this.setState({ unsendableAssetError });
render() {
const {
warning,
@ -47,9 +38,9 @@ export default class SendContent extends Component {
gasIsExcessive,
isEthGasPrice,
noGasPrice,
isAssetSendable,
} = this.props;
const { unsendableAssetError } = this.state;
let gasError;
if (gasIsExcessive) gasError = GAS_PRICE_EXCESSIVE_ERROR_KEY;
else if (noGasPrice) gasError = GAS_PRICE_FETCH_FAILURE_ERROR_KEY;
@ -59,18 +50,15 @@ export default class SendContent extends Component {
<div className="send-v2__form">
{gasError && this.renderError(gasError)}
{isEthGasPrice && this.renderWarning(ETH_GAS_PRICE_FETCH_WARNING_KEY)}
{unsendableAssetError && this.renderError(UNSENDABLE_ASSET_ERROR_KEY)}
{isAssetSendable === false &&
this.renderError(UNSENDABLE_ASSET_ERROR_KEY)}
{error && this.renderError(error)}
{warning && this.renderWarning()}
{this.maybeRenderAddContact()}
<SendAssetRow
setUnsendableAssetError={this.setUnsendableAssetError}
/>
<SendAmountRow updateGas={this.updateGas} />
<SendAssetRow />
<SendAmountRow />
<SendGasRow />
{this.props.showHexData && (
<SendHexDataRow updateGas={this.updateGas} />
)}
{this.props.showHexData && <SendHexDataRow />}
</div>
</PageContainerContent>
);

View File

@ -1,12 +1,13 @@
import { connect } from 'react-redux';
import {
getSendTo,
accountsWithSendEtherInfoSelector,
getAddressBookEntry,
getIsEthGasPriceFetched,
getNoGasPriceFetched,
} from '../../../selectors';
import { getIsAssetSendable, getSendTo } from '../../../ducks/send';
import * as actions from '../../../store/actions';
import SendContent from './send-content.component';
@ -14,15 +15,16 @@ function mapStateToProps(state) {
const ownedAccounts = accountsWithSendEtherInfoSelector(state);
const to = getSendTo(state);
return {
isAssetSendable: getIsAssetSendable(state),
isOwnedAccount: Boolean(
ownedAccounts.find(
({ address }) => address.toLowerCase() === to.toLowerCase(),
),
),
contact: getAddressBookEntry(state, to),
to,
isEthGasPrice: getIsEthGasPriceFetched(state),
noGasPrice: getNoGasPriceFetched(state),
to,
};
}

View File

@ -3,31 +3,25 @@ import PropTypes from 'prop-types';
import SendRowWrapper from '../send-row-wrapper';
import GasPriceButtonGroup from '../../../../components/app/gas-customization/gas-price-button-group';
import AdvancedGasInputs from '../../../../components/app/gas-customization/advanced-gas-inputs';
import { GAS_INPUT_MODES } from '../../../../ducks/send';
import GasFeeDisplay from './gas-fee-display/gas-fee-display.component';
export default class SendGasRow extends Component {
static propTypes = {
balance: PropTypes.string,
gasFeeError: PropTypes.bool,
gasLoadingError: PropTypes.bool,
gasTotal: PropTypes.string,
maxModeOn: PropTypes.bool,
showCustomizeGasModal: PropTypes.func,
sendToken: PropTypes.object,
setAmountToMax: PropTypes.func,
setGasPrice: PropTypes.func,
setGasLimit: PropTypes.func,
tokenBalance: PropTypes.string,
updateGasPrice: PropTypes.func,
updateGasLimit: PropTypes.func,
gasInputMode: PropTypes.oneOf(Object.values(GAS_INPUT_MODES)),
gasPriceButtonGroupProps: PropTypes.object,
gasButtonGroupShown: PropTypes.bool,
advancedInlineGasShown: PropTypes.bool,
resetGasButtons: PropTypes.func,
gasPrice: PropTypes.string,
gasLimit: PropTypes.string,
insufficientBalance: PropTypes.bool,
isMainnet: PropTypes.bool,
isEthGasPrice: PropTypes.bool,
noGasPrice: PropTypes.bool,
minimumGasLimit: PropTypes.string,
};
static contextTypes = {
@ -37,19 +31,7 @@ export default class SendGasRow extends Component {
renderAdvancedOptionsButton() {
const { trackEvent } = this.context;
const {
showCustomizeGasModal,
isMainnet,
isEthGasPrice,
noGasPrice,
} = this.props;
// Tests should behave in same way as mainnet, but are using Localhost
if (!isMainnet && !process.env.IN_TEST) {
return null;
}
if (isEthGasPrice || noGasPrice) {
return null;
}
const { showCustomizeGasModal } = this.props;
return (
<div
className="advanced-gas-options-btn"
@ -66,44 +48,22 @@ export default class SendGasRow extends Component {
);
}
setMaxAmount() {
const {
balance,
gasTotal,
sendToken,
setAmountToMax,
tokenBalance,
} = this.props;
setAmountToMax({
balance,
gasTotal,
sendToken,
tokenBalance,
});
}
renderContent() {
const {
gasLoadingError,
gasTotal,
showCustomizeGasModal,
gasPriceButtonGroupProps,
gasButtonGroupShown,
advancedInlineGasShown,
maxModeOn,
gasInputMode,
resetGasButtons,
setGasPrice,
setGasLimit,
updateGasPrice,
updateGasLimit,
gasPrice,
gasLimit,
insufficientBalance,
isMainnet,
isEthGasPrice,
noGasPrice,
minimumGasLimit,
} = this.props;
const { trackEvent } = this.context;
const gasPriceFetchFailure = isEthGasPrice || noGasPrice;
const gasPriceButtonGroup = (
<div>
@ -120,9 +80,6 @@ export default class SendGasRow extends Component {
},
});
await gasPriceButtonGroupProps.handleGasPriceSelection(opts);
if (maxModeOn) {
this.setMaxAmount();
}
}}
/>
</div>
@ -131,51 +88,38 @@ export default class SendGasRow extends Component {
<GasFeeDisplay
gasLoadingError={gasLoadingError}
gasTotal={gasTotal}
onReset={() => {
resetGasButtons();
if (maxModeOn) {
this.setMaxAmount();
}
}}
onClick={() => showCustomizeGasModal()}
onReset={resetGasButtons}
onClick={showCustomizeGasModal}
/>
);
const advancedGasInputs = (
<div>
<AdvancedGasInputs
updateCustomGasPrice={(newGasPrice) =>
setGasPrice({ gasPrice: newGasPrice, gasLimit })
}
updateCustomGasLimit={(newGasLimit) =>
setGasLimit(newGasLimit, gasPrice)
}
updateCustomGasPrice={updateGasPrice}
updateCustomGasLimit={updateGasLimit}
customGasPrice={gasPrice}
customGasLimit={gasLimit}
insufficientBalance={insufficientBalance}
minimumGasLimit={minimumGasLimit}
customPriceIsSafe
isSpeedUp={false}
/>
</div>
);
// Tests should behave in same way as mainnet, but are using Localhost
if (
advancedInlineGasShown ||
(!isMainnet && !process.env.IN_TEST) ||
gasPriceFetchFailure
) {
return advancedGasInputs;
} else if (gasButtonGroupShown) {
switch (gasInputMode) {
case GAS_INPUT_MODES.BASIC:
return gasPriceButtonGroup;
}
case GAS_INPUT_MODES.INLINE:
return advancedGasInputs;
case GAS_INPUT_MODES.CUSTOM:
default:
return gasFeeDisplay;
}
}
render() {
const {
gasFeeError,
gasButtonGroupShown,
advancedInlineGasShown,
} = this.props;
const { gasFeeError, gasInputMode, advancedInlineGasShown } = this.props;
return (
<>
@ -186,7 +130,7 @@ export default class SendGasRow extends Component {
>
{this.renderContent()}
</SendRowWrapper>
{gasButtonGroupShown || advancedInlineGasShown ? (
{gasInputMode === GAS_INPUT_MODES.BASIC || advancedInlineGasShown ? (
<SendRowWrapper>{this.renderAdvancedOptionsButton()}</SendRowWrapper>
) : null}
</>

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