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

Address book send plus contact list (#6914)

* Style Send Header

* Move Send to-row to send view and restyle

* Add "Recents" group to select recipient view

* Rename SendToRow to AddRecipient

* Basic UI and Layout

* New ENSInput component

* wip - fuzzy search for input

* small refactor

* Add Dialog

* contact list initial

* initial error on invalid address

* clean up edit

* Click to open modal

* Create AddToAddressBookModal component

* Modal styling and layout

* modal i18n

* Add to Addressbook

* ens wip

* ens wip

* ENS Resolution

* Reset input

* Send to explicit address

* Happy Path Complete

* Add back error checking

* Reset send-to when emptying input

* Add back warning object

* Fix linter

* Fix unit test #1 - fix import paths

* Remove dead tests

* One more to go

* Fix all unit tests

* add unit test for reducers and actions

* test rendering AddRecipient

* Add tests for dialog boxes in AddRecipient

* Add test for validating

* Fix linter

* Fix e2e tests

* Token send e2e fix

* Style View Contact

* Style edit-contact

* Fix e2e

* Fix from-import-beta-ui e2e spec

* Make section header say "add recipient” by default

* Auto-focus add recipient input

* Update placeholder text

* Update input title font size

* Auto advance to next step if user paste a valid address

* Ellipsify address when recipient is selected

* Fix app header background color on desktop

* Give each form row a margin of 16px

* Use .container/.component naming pattern for ens-input

* Auto-focus on input when add to addressbook modal is opened; Save on Enter

* Fix and add unit test

* Fix selectors name in e2e tests

* Correct e2e test token amount for address-book-send changes

* Adds e2e test for editing a transaction

* Delete test/integration/lib/send-new-ui.js

* Add tests for amount max button and high value error on send screen to test/e2e/metamask-ui.spec.js

* lint and revert to address as object keys

* add chainId based on current network to address book entry

* fix test

* only display contacts for the current network

* Improve ENS message when not found on current network

* Add error to indicate when network does not support ENS

* bump gaba

* address book, resolve comments

* Move contact-list to its own component

* De-duplicate getaddressbook selector and refactor name selection logic in contact-list-tab/

* Use contact-list component in contact-list-tab.component (i.e. in settings)

* Improve/fix settings headers for popup and browser views

* Lint fixes related to address book updates

* Add 'My accounts' page to settings address book

* Update add new contact button in settings to match floating circular design

* Improve styles of view contact page

* Improve styles and labels of the add-contact.component

* Further lint fixes related to address book updates

* Update unit tests as per address book updates

* Ensure that contact list groups are sorted alphabetically

* Refactor settings component to use a container for connection to redux; allow display of addressbook name in settings header

* Decouple ens-input.component from send context

* Add ens resolution to add contact screen in settings

* Switching networks when an ens address is shown on send form removes the ens address.

* Resolve send screen search for ensAddress to matching address book entry if it exists

* Show resolved ens icon and address if exists (settings: add-contact.component)

* Make the displayed and copied address in view-contact.component the checksummed address

* Default alias state prop in AddToAddressBookModal to empty string

* Use keyCode to detect enter key in AddToAddressBookModal

* Ensure add-contact component properly updates after QR code detection

* Fix display of all recents after clicking 'Load More' in contact list

* Fix send screen contact searching after network switching

* Code cleanup related to address book changes

* Update unit tests for address book changes

* Update ENS name not found on network message

* Add ens registration error message

* Cancel on edit mode takes user back to view screen

* Adds support for memo to settings contact list view and edit screens

* Modify designs of edit and view contact in popup environment

* Update settings content list UX to show split columns in fullscreen and proper internal navigation

* Correct background address book API usages in UI
This commit is contained in:
Dan J Miller 2019-07-31 17:26:44 -02:30 committed by GitHub
parent 1fd3dc9ecf
commit e9c7df28ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
98 changed files with 4147 additions and 1001 deletions

View File

@ -80,12 +80,21 @@
"activityLog": {
"message": "activity log"
},
"add": {
"message": "Add"
},
"address": {
"message": "Address"
},
"addNetwork": {
"message": "Add Network"
},
"addRecipient": {
"message": "Add Recipient"
},
"addressBook": {
"message": "Address Book"
},
"advanced": {
"message": "Advanced"
},
@ -98,6 +107,18 @@
"addCustomToken": {
"message": "Add custom token"
},
"addToAddressBook": {
"message": "Add to address book"
},
"addToAddressBookModalPlaceholder": {
"message": "e.g. John D."
},
"addAlias": {
"message": "Add alias"
},
"addEthAddress": {
"message": "Add an Ethereum address"
},
"addToken": {
"message": "Add Token"
},
@ -172,6 +193,9 @@
"back": {
"message": "Back"
},
"backToAll": {
"message": "Back to All"
},
"balance": {
"message": "Balance"
},
@ -354,6 +378,12 @@
"connectToTrezor": {
"message": "Connect to Trezor"
},
"contactList": {
"message": "Contact List"
},
"contactListDescription": {
"message": "Add, edit, remove, and manage your contacts"
},
"continue": {
"message": "Continue"
},
@ -463,6 +493,9 @@
"delete": {
"message": "Delete"
},
"deleteAccount": {
"message": "Delete Account"
},
"denExplainer": {
"message": "Your DEN is your password-encrypted storage within MetaMask."
},
@ -529,6 +562,9 @@
"editAccountName": {
"message": "Edit Account Name"
},
"editContact":{
"message": "Edit Contact"
},
"editingTransaction": {
"message": "Make changes to your transaction"
},
@ -571,6 +607,15 @@
"ensNameNotFound": {
"message": "ENS name not found"
},
"ensRegistrationError": {
"message": "Error in ENS name registration"
},
"ensNotFoundOnCurrentNetwork": {
"message": "ENS name not found on the current network. Try switching to Main Ethereum Network."
},
"enterAnAlias": {
"message": "Enter an alias"
},
"enterPassword": {
"message": "Enter password"
},
@ -583,6 +628,9 @@
"eth": {
"message": "ETH"
},
"ethereumPublicAddress": {
"message": "Ethereum Public Address"
},
"etherscanView": {
"message": "View account on Etherscan"
},
@ -893,6 +941,9 @@
"loadingTokens": {
"message": "Loading Tokens..."
},
"loadMore": {
"message": "Load More"
},
"localhost": {
"message": "Localhost 8545"
},
@ -914,6 +965,9 @@
"memorizePhrase": {
"message": "Memorize this phrase."
},
"memo": {
"message": "memo"
},
"menu": {
"message": "Menu"
},
@ -947,6 +1001,12 @@
"myAccounts": {
"message": "My Accounts"
},
"myWalletAccounts": {
"message": "My Wallet Accounts"
},
"myWalletAccountsDescription": {
"message": "All of your MetaMask created accounts will automatically be added to this section."
},
"mustSelectOne": {
"message": "Must select at least 1 token."
},
@ -979,10 +1039,16 @@
"newAccount": {
"message": "New Account"
},
"newAccountDetectedDialogMessage": {
"message": "New address detected! Click here to add to your address book."
},
"newAccountNumberName": {
"message": "Account $1",
"description": "Default name of next account to be created on create account screen"
},
"newContact": {
"message": "New Contact"
},
"newContract": {
"message": "New Contract"
},
@ -1193,9 +1259,15 @@
"receive": {
"message": "Receive"
},
"recents": {
"message": "Recents"
},
"recipientAddress": {
"message": "Recipient Address"
},
"recipientAddressPlaceholder": {
"message": "Search, public address (0x), or ENS"
},
"refundAddress": {
"message": "Your Refund Address"
},
@ -1670,6 +1742,9 @@
"transfer": {
"message": "Transfer"
},
"transferBetweenAccounts": {
"message": "Transfer between my accounts"
},
"transferFrom": {
"message": "Transfer From"
},
@ -1750,6 +1825,9 @@
"useOldUI": {
"message": "Use old UI"
},
"userName":{
"message": "Username"
},
"validFileImport": {
"message": "You must select a valid file to import."
},
@ -1762,6 +1840,9 @@
"viewinExplorer": {
"message": "View in Explorer"
},
"viewContact": {
"message": "View Contact"
},
"viewOnCustomBlockExplorer": {
"message": "View at $1"
},

View File

@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.833374 10C0.833374 4.9374 4.93743 0.833344 10 0.833344C15.0626 0.833344 19.1667 4.9374 19.1667 10C19.1667 15.0626 15.0626 19.1667 10 19.1667C4.93743 19.1667 0.833374 15.0626 0.833374 10Z" fill="#28A745"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.4256 6.70245C14.7511 7.02789 14.7511 7.55553 14.4256 7.88097L9.25303 13.2976C8.9276 13.6231 8.39996 13.6231 8.07452 13.2976L5.57452 10.7976C5.24909 10.4722 5.24909 9.94456 5.57452 9.61912C5.89996 9.29368 6.4276 9.29368 6.75303 9.61912L8.66378 11.5299L13.2471 6.70245C13.5725 6.37702 14.1002 6.37702 14.4256 6.70245Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 745 B

4
app/images/close-gray.svg Executable file
View File

@ -0,0 +1,4 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.14917" y="1.09723" width="1.34076" height="15.4188" rx="0.670381" transform="rotate(-45 0.14917 1.09723)" fill="#A1A5B3"/>
<rect x="0.94812" y="11.8508" width="1.34076" height="15.4188" rx="0.670381" transform="rotate(-135 0.94812 11.8508)" fill="#A1A5B3"/>
</svg>

After

Width:  |  Height:  |  Size: 372 B

7
app/images/qr-blue.svg Normal file
View File

@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.32 8H8.63997L8.63997 16H16.32V8ZM8.63997 6C7.57958 6 6.71997 6.89543 6.71997 8V16C6.71997 17.1046 7.57958 18 8.63997 18H16.32C17.3804 18 18.24 17.1046 18.24 16V8C18.24 6.89543 17.3804 6 16.32 6H8.63997Z" fill="#037DD6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.32 1C16.32 0.447715 16.7498 0 17.28 0H21.12C22.7106 0 24 1.34315 24 3V7C24 7.55228 23.5702 8 23.04 8C22.5098 8 22.08 7.55228 22.08 7V3C22.08 2.44772 21.6502 2 21.12 2H17.28C16.7498 2 16.32 1.55228 16.32 1Z" fill="#037DD6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.04 16C23.5702 16 24 16.4477 24 17L24 21C24 22.6569 22.7106 24 21.12 24L17.28 24C16.7498 24 16.32 23.5523 16.32 23C16.32 22.4477 16.7498 22 17.28 22L21.12 22C21.6502 22 22.08 21.5523 22.08 21L22.08 17C22.08 16.4477 22.5098 16 23.04 16Z" fill="#037DD6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.67999 23C7.67999 23.5523 7.25019 24 6.71999 24L2.87999 24C1.28941 24 -6.563e-06 22.6569 -6.42394e-06 21L-6.08824e-06 17C-6.04189e-06 16.4477 0.429801 16 0.959994 16C1.49019 16 1.91999 16.4477 1.91999 17L1.91999 21C1.91999 21.5523 2.3498 22 2.87999 22L6.71999 22C7.25019 22 7.67999 22.4477 7.67999 23Z" fill="#037DD6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.96 8C0.429807 8 5.87108e-08 7.55228 1.31134e-07 7L6.55671e-07 3C8.72941e-07 1.34315 1.28942 1.69087e-07 2.88 3.77666e-07L6.72 8.81222e-07C7.25019 9.50748e-07 7.68 0.447716 7.68 1C7.68 1.55229 7.25019 2 6.72 2L2.88 2C2.34981 2 1.92 2.44772 1.92 3L1.92 7C1.92 7.55229 1.49019 8 0.96 8Z" fill="#037DD6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.16667 3.33341C5.94501 3.33341 3.33334 5.94509 3.33334 9.16675C3.33334 12.3884 5.94501 15.0001 9.16667 15.0001C12.3883 15.0001 15 12.3884 15 9.16675C15 5.94509 12.3883 3.33341 9.16667 3.33341ZM1.66667 9.16675C1.66667 5.02461 5.02454 1.66675 9.16667 1.66675C13.3088 1.66675 16.6667 5.02461 16.6667 9.16675C16.6667 13.3089 13.3088 16.6667 9.16667 16.6667C5.02454 16.6667 1.66667 13.3089 1.66667 9.16675Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.2857 13.2858C13.6112 12.9604 14.1388 12.9604 14.4642 13.2858L18.0892 16.9108C18.4147 17.2363 18.4147 17.7639 18.0892 18.0893C17.7638 18.4148 17.2362 18.4148 16.9107 18.0893L13.2857 14.4643C12.9603 14.1389 12.9603 13.6113 13.2857 13.2858Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 883 B

View File

@ -460,6 +460,7 @@ module.exports = class MetamaskController extends EventEmitter {
// AddressController
setAddressBook: this.addressBookController.set.bind(this.addressBookController),
removeFromAddressBook: this.addressBookController.delete.bind(this.addressBookController),
// AppStateController
setLastActiveTime: nodeify(this.appStateController.setLastActiveTime, this.appStateController),

View File

@ -101,7 +101,7 @@
"extensionizer": "^1.0.1",
"fast-json-patch": "^2.0.4",
"fuse.js": "^3.2.0",
"gaba": "^1.4.1",
"gaba": "^1.5.0",
"human-standard-token-abi": "^2.0.0",
"jazzicon": "^1.2.0",
"json-rpc-engine": "^4.0.0",

View File

@ -119,7 +119,8 @@
"addressBook": [
{
"address": "0xc42edfcc21ed14dda456aa0756c153f7985d8813",
"name": ""
"name": "",
"chainId": 4
}
],
"selectedTokenAddress": "0x108cf70c7d384c552f42c07c41c0e1e46d77ea0d",

View File

@ -255,9 +255,14 @@ describe('Using MetaMask with an existing account', function () {
await sendButton.click()
await delay(regularDelayMs)
const inputAddress = await findElement(driver, By.css('input[placeholder="Recipient Address"]'))
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
const inputAddress = await findElement(driver, By.css('input[placeholder="Search, public address (0x), or ENS"]'))
await inputAddress.sendKeys('0x2f318C334780961FB129D2a6c30D0763d9a5C970')
const recipientRow = await findElement(driver, By.css('.send__select-recipient-wrapper__group-item'))
await recipientRow.click()
await delay(regularDelayMs)
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
await inputAmount.sendKeys('1')
// Set the gas limit

View File

@ -276,9 +276,14 @@ describe('MetaMask', function () {
await sendButton.click()
await delay(regularDelayMs)
const inputAddress = await findElement(driver, By.css('input[placeholder="Recipient Address"]'))
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
const inputAddress = await findElement(driver, By.css('input[placeholder="Search, public address (0x), or ENS"]'))
await inputAddress.sendKeys('0x2f318C334780961FB129D2a6c30D0763d9a5C970')
const recipientRow = await findElement(driver, By.css('.send__select-recipient-wrapper__group-item'))
await recipientRow.click()
await delay(regularDelayMs)
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
await inputAmount.sendKeys('1')
const inputValue = await inputAmount.getAttribute('value')

View File

@ -322,12 +322,44 @@ describe('MetaMask', function () {
await sendButton.click()
await delay(regularDelayMs)
const inputAddress = await findElement(driver, By.css('input[placeholder="Recipient Address"]'))
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
const inputAddress = await findElement(driver, By.css('input[placeholder="Search, public address (0x), or ENS"]'))
await inputAddress.sendKeys('0x2f318C334780961FB129D2a6c30D0763d9a5C970')
const recipientRow = await findElement(driver, By.css('.send__select-recipient-wrapper__group-item'))
await recipientRow.click()
await delay(regularDelayMs)
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
await inputAmount.sendKeys('1000')
const errorAmount = await findElement(driver, By.css('.send-v2__error-amount'))
assert.equal(await errorAmount.getText(), 'Insufficient funds.', 'send screen should render an insufficient fund error message')
await inputAmount.sendKeys(Key.BACK_SPACE)
await delay(50)
await inputAmount.sendKeys(Key.BACK_SPACE)
await delay(50)
await inputAmount.sendKeys(Key.BACK_SPACE)
await delay(tinyDelayMs)
await assertElementNotPresent(webdriver, driver, By.css('.send-v2__error-amount'))
const amountMax = await findElement(driver, By.css('.send-v2__amount-max'))
await amountMax.click()
assert.equal(await inputAmount.isEnabled(), false)
let inputValue = await inputAmount.getAttribute('value')
assert(Number(inputValue) > 99)
await amountMax.click()
assert.equal(await inputAmount.isEnabled(), true)
await inputAmount.sendKeys('1')
const inputValue = await inputAmount.getAttribute('value')
inputValue = await inputAmount.getAttribute('value')
assert.equal(inputValue, '1')
await delay(regularDelayMs)
@ -360,9 +392,14 @@ describe('MetaMask', function () {
await sendButton.click()
await delay(regularDelayMs)
const inputAddress = await findElement(driver, By.css('input[placeholder="Recipient Address"]'))
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
const inputAddress = await findElement(driver, By.css('input[placeholder="Search, public address (0x), or ENS"]'))
await inputAddress.sendKeys('0x2f318C334780961FB129D2a6c30D0763d9a5C970')
const recipientRow = await findElement(driver, By.css('.send__select-recipient-wrapper__group-item'))
await recipientRow.click()
await delay(regularDelayMs)
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
await inputAmount.sendKeys('1')
const inputValue = await inputAmount.getAttribute('value')
@ -402,9 +439,14 @@ describe('MetaMask', function () {
await sendButton.click()
await delay(regularDelayMs)
const inputAddress = await findElement(driver, By.css('input[placeholder="Recipient Address"]'))
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
const inputAddress = await findElement(driver, By.css('input[placeholder="Search, public address (0x), or ENS"]'))
await inputAddress.sendKeys('0x2f318C334780961FB129D2a6c30D0763d9a5C970')
const recipientRow = await findElement(driver, By.css('.send__select-recipient-wrapper__group-item'))
await recipientRow.click()
await delay(regularDelayMs)
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
await inputAmount.sendKeys('1')
const inputValue = await inputAmount.getAttribute('value')
@ -1005,9 +1047,14 @@ describe('MetaMask', function () {
await sendButton.click()
await delay(regularDelayMs)
const inputAddress = await findElement(driver, By.css('input[placeholder="Recipient Address"]'))
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
const inputAddress = await findElement(driver, By.css('input[placeholder="Search, public address (0x), or ENS"]'))
await inputAddress.sendKeys('0x2f318C334780961FB129D2a6c30D0763d9a5C970')
const recipientRow = await findElement(driver, By.css('.send__select-recipient-wrapper__group-item'))
await recipientRow.click()
await delay(regularDelayMs)
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
await inputAmount.sendKeys('1')
// Set the gas limit

View File

@ -31,3 +31,11 @@ concurrently --kill-others \
--success first \
'yarn ganache:start' \
'sleep 5 && mocha test/e2e/from-import-ui.spec'
export GANACHE_ARGS="$GANACHE_ARGS --deterministic --account=0x53CB0AB5226EEBF4D872113D98332C1555DC304443BEE1CF759D15798D3C55A9,25000000000000000000"
concurrently --kill-others \
--names 'ganache,e2e' \
--prefix '[{time}][{name}]' \
--success first \
'npm run ganache:start' \
'sleep 5 && mocha test/e2e/send-edit.spec'

288
test/e2e/send-edit.spec.js Normal file
View File

@ -0,0 +1,288 @@
const path = require('path')
const assert = require('assert')
const webdriver = require('selenium-webdriver')
const { By, Key, until } = webdriver
const {
delay,
buildChromeWebDriver,
buildFirefoxWebdriver,
installWebExt,
getExtensionIdChrome,
getExtensionIdFirefox,
} = require('./func')
const {
checkBrowserForConsoleErrors,
closeAllWindowHandlesExcept,
verboseReportOnFailure,
findElement,
findElements,
} = require('./helpers')
const fetchMockResponses = require('./fetch-mocks.js')
describe('Using MetaMask with an existing account', function () {
let extensionId
let driver
const testSeedPhrase = 'forum vessel pink push lonely enact gentle tail admit parrot grunt dress'
const tinyDelayMs = 200
const regularDelayMs = 1000
const largeDelayMs = regularDelayMs * 2
this.timeout(0)
this.bail(true)
before(async function () {
let extensionUrl
switch (process.env.SELENIUM_BROWSER) {
case 'chrome': {
const extensionPath = path.resolve('dist/chrome')
driver = buildChromeWebDriver(extensionPath)
extensionId = await getExtensionIdChrome(driver)
await delay(regularDelayMs)
extensionUrl = `chrome-extension://${extensionId}/home.html`
break
}
case 'firefox': {
const extensionPath = path.resolve('dist/firefox')
driver = buildFirefoxWebdriver()
await installWebExt(driver, extensionPath)
await delay(regularDelayMs)
extensionId = await getExtensionIdFirefox(driver)
extensionUrl = `moz-extension://${extensionId}/home.html`
break
}
}
// Depending on the state of the application built into the above directory (extPath) and the value of
// METAMASK_DEBUG we will see different post-install behaviour and possibly some extra windows. Here we
// are closing any extraneous windows to reset us to a single window before continuing.
const [tab1] = await driver.getAllWindowHandles()
await closeAllWindowHandlesExcept(driver, [tab1])
await driver.switchTo().window(tab1)
await driver.get(extensionUrl)
})
beforeEach(async function () {
await driver.executeScript(
'window.origFetch = window.fetch.bind(window);' +
'window.fetch = ' +
'(...args) => { ' +
'if (args[0] === "https://ethgasstation.info/json/ethgasAPI.json") { return ' +
'Promise.resolve({ json: () => Promise.resolve(JSON.parse(\'' + fetchMockResponses.ethGasBasic + '\')) }); } else if ' +
'(args[0] === "https://ethgasstation.info/json/predictTable.json") { return ' +
'Promise.resolve({ json: () => Promise.resolve(JSON.parse(\'' + fetchMockResponses.ethGasPredictTable + '\')) }); } else if ' +
'(args[0].match(/chromeextensionmm/)) { return ' +
'Promise.resolve({ json: () => Promise.resolve(JSON.parse(\'' + fetchMockResponses.metametrics + '\')) }); } else if ' +
'(args[0] === "https://dev.blockscale.net/api/gasexpress.json") { return ' +
'Promise.resolve({ json: () => Promise.resolve(JSON.parse(\'' + fetchMockResponses.gasExpress + '\')) }); } ' +
'return window.origFetch(...args); };' +
'function cancelInfuraRequest(requestDetails) {' +
'console.log("Canceling: " + requestDetails.url);' +
'return {' +
'cancel: true' +
'};' +
' }' +
'window.chrome && window.chrome.webRequest && window.chrome.webRequest.onBeforeRequest.addListener(' +
'cancelInfuraRequest,' +
'{urls: ["https://*.infura.io/*"]},' +
'["blocking"]' +
');'
)
})
afterEach(async function () {
if (process.env.SELENIUM_BROWSER === 'chrome') {
const errors = await checkBrowserForConsoleErrors(driver)
if (errors.length) {
const errorReports = errors.map(err => err.message)
const errorMessage = `Errors found in browser console:\n${errorReports.join('\n')}`
console.error(new Error(errorMessage))
}
}
if (this.currentTest.state === 'failed') {
await verboseReportOnFailure(driver, this.currentTest)
}
})
after(async function () {
await driver.quit()
})
describe('First time flow starting from an existing seed phrase', () => {
it('clicks the continue button on the welcome screen', async () => {
await findElement(driver, By.css('.welcome-page__header'))
const welcomeScreenBtn = await findElement(driver, By.css('.first-time-flow__button'))
welcomeScreenBtn.click()
await delay(largeDelayMs)
})
it('clicks the "Import Wallet" option', async () => {
const customRpcButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Import Wallet')]`))
customRpcButton.click()
await delay(largeDelayMs)
})
it('clicks the "No thanks" option on the metametrics opt-in screen', async () => {
const optOutButton = await findElement(driver, By.css('.btn-default'))
optOutButton.click()
await delay(largeDelayMs)
})
it('imports a seed phrase', async () => {
const [seedTextArea] = await findElements(driver, By.css('textarea.first-time-flow__textarea'))
await seedTextArea.sendKeys(testSeedPhrase)
await delay(regularDelayMs)
const [password] = await findElements(driver, By.id('password'))
await password.sendKeys('correct horse battery staple')
const [confirmPassword] = await findElements(driver, By.id('confirm-password'))
confirmPassword.sendKeys('correct horse battery staple')
const tosCheckBox = await findElement(driver, By.css('.first-time-flow__checkbox'))
await tosCheckBox.click()
const [importButton] = await findElements(driver, By.xpath(`//button[contains(text(), 'Import')]`))
await importButton.click()
await delay(regularDelayMs)
})
it('clicks through the success screen', async () => {
await findElement(driver, By.xpath(`//div[contains(text(), 'Congratulations')]`))
const doneButton = await findElement(driver, By.css('button.first-time-flow__button'))
await doneButton.click()
await delay(regularDelayMs)
})
})
describe('Send ETH from inside MetaMask', () => {
it('starts a send transaction', async function () {
const sendButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Send')]`))
await sendButton.click()
await delay(regularDelayMs)
const inputAddress = await findElement(driver, By.css('input[placeholder="Search, public address (0x), or ENS"]'))
await inputAddress.sendKeys('0x2f318C334780961FB129D2a6c30D0763d9a5C970')
const recipientRow = await findElement(driver, By.css('.send__select-recipient-wrapper__group-item'))
await recipientRow.click()
await delay(regularDelayMs)
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
await inputAmount.sendKeys('1')
// Set the gas limit
const configureGas = await findElement(driver, By.css('.advanced-gas-options-btn'))
await configureGas.click()
await delay(regularDelayMs)
const gasModal = await driver.findElement(By.css('span .modal'))
const [gasPriceInput, gasLimitInput] = await findElements(driver, By.css('.advanced-tab__gas-edit-row__input'))
await gasPriceInput.sendKeys(Key.chord(Key.CONTROL, 'a'))
await delay(50)
await gasPriceInput.sendKeys(Key.BACK_SPACE)
await delay(50)
await gasPriceInput.sendKeys(Key.BACK_SPACE)
await delay(50)
await gasPriceInput.sendKeys('10')
await delay(50)
await delay(tinyDelayMs)
await delay(50)
await gasLimitInput.sendKeys(Key.chord(Key.CONTROL, 'a'))
await delay(50)
await gasLimitInput.sendKeys('25000')
const save = await findElement(driver, By.xpath(`//button[contains(text(), 'Save')]`))
await save.click()
await driver.wait(until.stalenessOf(gasModal))
await delay(regularDelayMs)
// Continue to next screen
const nextScreen = await findElement(driver, By.xpath(`//button[contains(text(), 'Next')]`))
await nextScreen.click()
await delay(regularDelayMs)
})
it('has correct value and fee on the confirm screen the transaction', async function () {
const transactionAmounts = await findElements(driver, By.css('.currency-display-component__text'))
const transactionAmount = transactionAmounts[0]
assert.equal(await transactionAmount.getText(), '1')
const transactionFee = transactionAmounts[1]
assert.equal(await transactionFee.getText(), '0.00025')
})
it('edits the transaction', async function () {
const editButton = await findElement(driver, By.css('.confirm-page-container-header__back-button'))
await editButton.click()
await delay(regularDelayMs)
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
await inputAmount.sendKeys(Key.chord(Key.CONTROL, 'a'))
await delay(50)
await inputAmount.sendKeys(Key.BACK_SPACE)
await delay(50)
await inputAmount.sendKeys('2.2')
const configureGas = await findElement(driver, By.css('.advanced-gas-options-btn'))
await configureGas.click()
await delay(regularDelayMs)
const gasModal = await driver.findElement(By.css('span .modal'))
const [gasPriceInput, gasLimitInput] = await findElements(driver, By.css('.advanced-tab__gas-edit-row__input'))
await gasPriceInput.sendKeys(Key.chord(Key.CONTROL, 'a'))
await delay(50)
await gasPriceInput.sendKeys(Key.BACK_SPACE)
await delay(50)
await gasPriceInput.sendKeys(Key.BACK_SPACE)
await delay(50)
await gasPriceInput.sendKeys('8')
await delay(50)
await delay(tinyDelayMs)
await delay(50)
await gasLimitInput.sendKeys(Key.chord(Key.CONTROL, 'a'))
await delay(50)
await gasLimitInput.sendKeys('100000')
const save = await findElement(driver, By.xpath(`//button[contains(text(), 'Save')]`))
await save.click()
await driver.wait(until.stalenessOf(gasModal))
await delay(regularDelayMs)
const nextScreen = await findElement(driver, By.xpath(`//button[contains(text(), 'Next')]`))
await nextScreen.click()
await delay(regularDelayMs)
})
it('has correct updated value on the confirm screen the transaction', async function () {
const transactionAmounts = await findElements(driver, By.css('.currency-display-component__text'))
const transactionAmount = transactionAmounts[0]
assert.equal(await transactionAmount.getText(), '2.2')
const transactionFee = transactionAmounts[1]
assert.equal(await transactionFee.getText(), '0.0008')
})
it('confirms the transaction', async function () {
const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`))
await confirmButton.click()
await delay(regularDelayMs)
})
it('finds the transaction in the transactions list', async function () {
const transactions = await findElements(driver, By.css('.transaction-list-item'))
assert.equal(transactions.length, 1)
const txValues = await findElements(driver, By.css('.transaction-list-item__amount--primary'))
assert.equal(txValues.length, 1)
assert.ok(/-2.2\s*ETH/.test(await txValues[0].getText()))
})
})
})

View File

@ -1,168 +0,0 @@
const reactTriggerChange = require('../../lib/react-trigger-change')
const {
timeout,
queryAsync,
findAsync,
} = require('../../lib/util')
const fetchMockResponses = require('../../e2e/fetch-mocks.js')
QUnit.module('new ui send flow')
QUnit.test('successful send flow', (assert) => {
const done = assert.async()
runSendFlowTest(assert).then(done).catch((err) => {
assert.notOk(err, `Error was thrown: ${err.stack}`)
done()
})
})
global.ethQuery = {
sendTransaction: () => {},
}
global.ethereumProvider = {}
async function runSendFlowTest (assert) {
const tempFetch = global.fetch
const realFetch = window.fetch.bind(window)
global.fetch = (...args) => {
if (args[0] === 'https://ethgasstation.info/json/ethgasAPI.json') {
return Promise.resolve({ json: () => Promise.resolve(JSON.parse(fetchMockResponses.ethGasBasic)) })
} else if (args[0] === 'https://ethgasstation.info/json/predictTable.json') {
return Promise.resolve({ json: () => Promise.resolve(JSON.parse(fetchMockResponses.ethGasPredictTable)) })
} else if (args[0] === 'https://dev.blockscale.net/api/gasexpress.json') {
return Promise.resolve({ json: () => Promise.resolve(JSON.parse(fetchMockResponses.gasExpress)) })
} else if (args[0].match(/chromeextensionmm/)) {
return Promise.resolve({ json: () => Promise.resolve(JSON.parse(fetchMockResponses.metametrics)) })
}
return realFetch.fetch(...args)
}
console.log('*** start runSendFlowTest')
const selectState = await queryAsync($, 'select')
selectState.val('send new ui')
reactTriggerChange(selectState[0])
const sendScreenButton = await queryAsync($, 'button.btn-secondary.transaction-view-balance__button')
assert.ok(sendScreenButton[1], 'send screen button present')
sendScreenButton[1].click()
const sendTitle = await queryAsync($, '.page-container__title')
assert.equal(sendTitle[0].textContent, 'Send ETH', 'Send screen title is correct')
const sendFromField = await queryAsync($, '.send-v2__form-field')
assert.ok(sendFromField[0], 'send screen has a from field')
const sendFromFieldItemAddress = await queryAsync($, '.account-list-item__account-name')
assert.equal(sendFromFieldItemAddress[0].textContent, 'Send Account 2', 'send from field shows correct account name')
const sendToFieldInput = await queryAsync($, '.send-v2__to-autocomplete__input')
sendToFieldInput[0].focus()
await timeout(1000)
const sendToDropdownList = await queryAsync($, '.send-v2__from-dropdown__list')
assert.equal(sendToDropdownList.children().length, 5, 'send to dropdown shows all accounts and address book accounts')
sendToDropdownList.children()[2].click()
const sendToAccountAddress = sendToFieldInput.val()
assert.equal(sendToAccountAddress, '0x2f8D4a878cFA04A6E60D46362f5644DeAb66572D', 'send to dropdown selects the correct address')
const sendAmountField = await queryAsync($, '.send-v2__form-row:eq(3)')
const sendAmountFieldInput = await findAsync(sendAmountField, '.unit-input__input')
const amountMaxButton = await queryAsync($, '.send-v2__amount-max')
amountMaxButton.click()
reactTriggerChange(sendAmountField.find('input')[1])
assert.equal(sendAmountFieldInput.is(':disabled'), true, 'disabled the send amount input when max mode is on')
const gasPriceButtonGroup = await queryAsync($, '.gas-price-button-group--small')
const gasPriceButton = await gasPriceButtonGroup.find('button')[0]
const valueBeforeGasPriceChange = sendAmountFieldInput.prop('value')
gasPriceButton.click()
reactTriggerChange(sendAmountField.find('input')[1])
await timeout(1000)
assert.notEqual(valueBeforeGasPriceChange, sendAmountFieldInput.prop('value'), 'send amount value changes when gas price changes')
amountMaxButton.click()
reactTriggerChange(sendAmountField.find('input')[1])
sendAmountField.find('.unit-input').click()
sendAmountFieldInput.val('5.1')
reactTriggerChange(sendAmountField.find('input')[1])
let errorMessage = await queryAsync($, '.send-v2__error')
assert.equal(errorMessage[0].textContent, 'Insufficient funds.', 'send should render an insufficient fund error message')
sendAmountFieldInput.val('2.0')
reactTriggerChange(sendAmountFieldInput[0])
await timeout()
errorMessage = $('.send-v2__error')
assert.equal(errorMessage.length, 0, 'send should stop rendering amount error message after amount is corrected')
const sendButton = await queryAsync($, 'button.btn-secondary.btn--large.page-container__footer-button')
assert.equal(sendButton[0].textContent, 'Next', 'next button rendered')
sendButton[0].click()
await timeout()
selectState.val('send edit')
reactTriggerChange(selectState[0])
const confirmFromName = (await queryAsync($, '.sender-to-recipient__name')).first()
assert.equal(confirmFromName[0].textContent, 'Send Account 2', 'confirm screen should show correct from name')
const confirmToName = (await queryAsync($, '.sender-to-recipient__name')).last()
assert.equal(confirmToName[0].textContent, 'Send Account 3', 'confirm screen should show correct to name')
const confirmScreenRowFiats = await queryAsync($, '.confirm-detail-row__secondary')
const confirmScreenGas = confirmScreenRowFiats[0]
assert.equal(confirmScreenGas.textContent, '$3.60', 'confirm screen should show correct gas')
const confirmScreenTotal = confirmScreenRowFiats[1]
assert.equal(confirmScreenTotal.textContent, '$2,405.37', 'confirm screen should show correct total')
const confirmScreenBackButton = await queryAsync($, '.confirm-page-container-header__back-button')
confirmScreenBackButton[0].click()
const sendToFieldInputInEdit = await queryAsync($, '.send-v2__to-autocomplete__input')
sendToFieldInputInEdit[0].focus()
sendToFieldInputInEdit.val('0xd85a4b6a394794842887b8284293d69163007bbb')
const sendAmountFieldInEdit = await queryAsync($, '.send-v2__form-row:eq(3)')
sendAmountFieldInEdit.find('.unit-input')[0].click()
const sendAmountFieldInputInEdit = sendAmountFieldInEdit.find('.unit-input__input')
sendAmountFieldInputInEdit.val('1.0')
reactTriggerChange(sendAmountFieldInputInEdit[0])
const sendButtonInEdit = await queryAsync($, '.btn-secondary.btn--large.page-container__footer-button')
assert.equal(sendButtonInEdit[0].textContent, 'Next', 'next button in edit rendered')
selectState.val('send new ui')
reactTriggerChange(selectState[0])
const cancelButtonInEdit = await queryAsync($, '.btn-default.btn--large.page-container__footer-button')
cancelButtonInEdit[0].click()
global.fetch = tempFetch
// sendButtonInEdit[0].click()
// // TODO: Need a way to mock background so that we can test correct transition from editing to confirm
// selectState.val('confirm new ui')
// reactTriggerChange(selectState[0])
// const confirmScreenConfirmButton = await queryAsync($, '.btn-confirm.page-container__footer-button')
// console.log(`+++++++++++++++++++++++++++++++= confirmScreenConfirmButton[0]`, confirmScreenConfirmButton[0]);
// confirmScreenConfirmButton[0].click()
// await timeout(10000000)
// const txView = await queryAsync($, '.tx-view')
// console.log(`++++++++++++++++++++++++++++++++ txView[0]`, txView[0]);
// assert.ok(txView[0], 'Should return to the account details screen after confirming')
}

View File

@ -869,7 +869,7 @@ describe('Actions', () => {
})
it('', () => {
const store = mockStore()
const store = mockStore({ metamask: devState })
store.dispatch(actions.addToAddressBook('test'))
assert(addToAddressBookSpy.calledOnce)
})

View File

@ -309,6 +309,8 @@ describe('MetaMask Reducers', () => {
errors: {},
editingTransactionId: 22,
forceGasMin: '0xGas',
ensResolution: null,
ensResolutionError: '',
}
const sendState = reduceMetamask({}, {
@ -492,4 +494,24 @@ describe('MetaMask Reducers', () => {
assert.deepEqual(state.pendingTokens, {})
})
it('update ensResolution', () => {
const state = reduceMetamask({}, {
type: actions.UPDATE_SEND_ENS_RESOLUTION,
payload: '0x1337',
})
assert.deepEqual(state.send.ensResolution, '0x1337')
assert.deepEqual(state.send.ensResolutionError, '')
})
it('update ensResolutionError', () => {
const state = reduceMetamask({}, {
type: actions.UPDATE_SEND_ENS_RESOLUTION_ERROR,
payload: 'ens name not found',
})
assert.deepEqual(state.send.ensResolutionError, 'ens name not found')
assert.deepEqual(state.send.ensResolution, null)
})
})

View File

@ -10,7 +10,6 @@
@media screen and (max-width: 575px) {
padding: 1rem;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .08);
z-index: $mobile-header-z-index;
}
@ -24,7 +23,7 @@
position: absolute;
width: 100%;
height: 32px;
background: $gallery;
background: $Grey-000;
bottom: -32px;
}
}

View File

@ -0,0 +1,114 @@
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import RecipientGroup from './recipient-group/recipient-group.component'
export default class ContactList extends PureComponent {
static propTypes = {
searchForContacts: PropTypes.func,
searchForRecents: PropTypes.func,
searchForMyAccounts: PropTypes.func,
selectRecipient: PropTypes.func,
children: PropTypes.node,
selectedAddress: PropTypes.string,
}
static contextTypes = {
t: PropTypes.func,
}
state = {
isShowingAllRecent: false,
}
renderRecents () {
const { t } = this.context
const { isShowingAllRecent } = this.state
const nonContacts = this.props.searchForRecents()
const showLoadMore = !isShowingAllRecent && nonContacts.length > 2
return (
<div className="send__select-recipient-wrapper__recent-group-wrapper">
<RecipientGroup
label={t('recents')}
items={showLoadMore ? nonContacts.slice(0, 2) : nonContacts}
onSelect={this.props.selectRecipient}
selectedAddress={this.props.selectedAddress}
/>
{
showLoadMore && (
<div
className="send__select-recipient-wrapper__recent-group-wrapper__load-more"
onClick={() => this.setState({ isShowingAllRecent: true })}
>
{t('loadMore')}
</div>
)
}
</div>
)
}
renderAddressBook () {
const contacts = this.props.searchForContacts()
const contactGroups = contacts.reduce((acc, contact) => {
const firstLetter = contact.name.slice(0, 1).toUpperCase()
acc[firstLetter] = acc[firstLetter] || []
const bucket = acc[firstLetter]
bucket.push(contact)
return acc
}, {})
return Object
.entries(contactGroups)
.sort(([letter1], [letter2]) => {
if (letter1 > letter2) {
return 1
} else if (letter1 === letter2) {
return 0
} else if (letter1 < letter2) {
return -1
}
})
.map(([letter, groupItems]) => (
<RecipientGroup
key={`${letter}-contract-group`}
label={letter}
items={groupItems}
onSelect={this.props.selectRecipient}
selectedAddress={this.props.selectedAddress}
/>
))
}
renderMyAccounts () {
const myAccounts = this.props.searchForMyAccounts()
return (
<RecipientGroup
items={myAccounts}
onSelect={this.props.selectRecipient}
selectedAddress={this.props.selectedAddress}
/>
)
}
render () {
const {
children,
searchForRecents,
searchForContacts,
searchForMyAccounts,
} = this.props
return (
<div className="send__select-recipient-wrapper__list">
{ children || null }
{ searchForRecents && this.renderRecents() }
{ searchForContacts && this.renderAddressBook() }
{ searchForMyAccounts && this.renderMyAccounts() }
</div>
)
}
}

View File

@ -0,0 +1 @@
export { default } from './contact-list.component'

View File

@ -0,0 +1 @@
export { default } from './recipient-group.component'

View File

@ -0,0 +1,59 @@
import React from 'react'
import PropTypes from 'prop-types'
import Identicon from '../../../ui/identicon'
import classnames from 'classnames'
import { ellipsify } from '../../../../pages/send/send.utils'
function addressesEqual (address1, address2) {
return String(address1).toLowerCase() === String(address2).toLowerCase()
}
export default function RecipientGroup ({ label, items, onSelect, selectedAddress }) {
if (!items || !items.length) {
return null
}
return (
<div className="send__select-recipient-wrapper__group">
{label && <div className="send__select-recipient-wrapper__group-label">
{label}
</div>}
{
items.map(({ address, name }) => (
<div
key={address}
onClick={() => onSelect(address, name)}
className={classnames({
'send__select-recipient-wrapper__group-item': !addressesEqual(address, selectedAddress),
'send__select-recipient-wrapper__group-item--selected': addressesEqual(address, selectedAddress),
})}
>
<Identicon address={address} diameter={28} />
<div className="send__select-recipient-wrapper__group-item__content">
<div className="send__select-recipient-wrapper__group-item__title">
{name || ellipsify(address)}
</div>
{
name && (
<div className="send__select-recipient-wrapper__group-item__subtitle">
{ellipsify(address)}
</div>
)
}
</div>
</div>
))
}
</div>
)
}
RecipientGroup.propTypes = {
label: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
address: PropTypes.string,
name: PropTypes.string,
})),
onSelect: PropTypes.func.isRequired,
selectedAddress: PropTypes.string,
}

View File

@ -1,181 +0,0 @@
const Component = require('react').Component
const PropTypes = require('prop-types')
const h = require('react-hyperscript')
const inherits = require('util').inherits
const extend = require('xtend')
const debounce = require('debounce')
const copyToClipboard = require('copy-to-clipboard')
const ENS = require('ethjs-ens')
const networkMap = require('ethjs-ens/lib/network-map.json')
const ensRE = /.+\..+$/
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
const connect = require('react-redux').connect
const ToAutoComplete = require('../../pages/send/to-autocomplete').default
const log = require('loglevel')
const { isValidENSAddress } = require('../../helpers/utils/util')
EnsInput.contextTypes = {
t: PropTypes.func,
}
module.exports = connect()(EnsInput)
inherits(EnsInput, Component)
function EnsInput () {
Component.call(this)
}
EnsInput.prototype.onChange = function (recipient) {
const network = this.props.network
const networkHasEnsSupport = getNetworkEnsSupport(network)
this.props.onChange({ toAddress: recipient })
if (!networkHasEnsSupport) return
if (recipient.match(ensRE) === null) {
return this.setState({
loadingEns: false,
ensResolution: null,
ensFailure: null,
toError: null,
})
}
this.setState({
loadingEns: true,
})
this.checkName(recipient)
}
EnsInput.prototype.render = function () {
const props = this.props
const opts = extend(props, {
list: 'addresses',
onChange: this.onChange.bind(this),
qrScanner: true,
})
return h('div', {
style: { width: '100%', position: 'relative' },
}, [
h(ToAutoComplete, { ...opts }),
this.ensIcon(),
])
}
EnsInput.prototype.componentDidMount = function () {
const network = this.props.network
const networkHasEnsSupport = getNetworkEnsSupport(network)
this.setState({ ensResolution: ZERO_ADDRESS })
if (networkHasEnsSupport) {
const provider = global.ethereumProvider
this.ens = new ENS({ provider, network })
this.checkName = debounce(this.lookupEnsName.bind(this), 200)
}
}
EnsInput.prototype.lookupEnsName = function (recipient) {
const { ensResolution } = this.state
log.info(`ENS attempting to resolve name: ${recipient}`)
this.ens.lookup(recipient.trim())
.then((address) => {
if (address === ZERO_ADDRESS) throw new Error(this.context.t('noAddressForName'))
if (address !== ensResolution) {
this.setState({
loadingEns: false,
ensResolution: address,
nickname: recipient.trim(),
hoverText: address + '\n' + this.context.t('clickCopy'),
ensFailure: false,
toError: null,
})
}
})
.catch((reason) => {
const setStateObj = {
loadingEns: false,
ensResolution: recipient,
ensFailure: true,
toError: null,
}
if (isValidENSAddress(recipient) && reason.message === 'ENS name not defined.') {
setStateObj.hoverText = this.context.t('ensNameNotFound')
setStateObj.toError = 'ensNameNotFound'
setStateObj.ensFailure = false
} else {
log.error(reason)
setStateObj.hoverText = reason.message
}
return this.setState(setStateObj)
})
}
EnsInput.prototype.componentDidUpdate = function (prevProps, prevState) {
const state = this.state || {}
const ensResolution = state.ensResolution
// If an address is sent without a nickname, meaning not from ENS or from
// the user's own accounts, a default of a one-space string is used.
const nickname = state.nickname || ' '
if (prevProps.network !== this.props.network) {
const provider = global.ethereumProvider
this.ens = new ENS({ provider, network: this.props.network })
this.onChange(ensResolution)
}
if (prevState && ensResolution && this.props.onChange &&
ensResolution !== prevState.ensResolution) {
this.props.onChange({ toAddress: ensResolution, nickname, toError: state.toError, toWarning: state.toWarning })
}
}
EnsInput.prototype.ensIcon = function (recipient) {
const { hoverText } = this.state || {}
return h('span.#ensIcon', {
title: hoverText,
style: {
position: 'absolute',
top: '16px',
left: '-25px',
},
}, this.ensIconContents(recipient))
}
EnsInput.prototype.ensIconContents = function () {
const { loadingEns, ensFailure, ensResolution, toError } = this.state || { ensResolution: ZERO_ADDRESS }
if (toError) return
if (loadingEns) {
return h('img', {
src: 'images/loading.svg',
style: {
width: '30px',
height: '30px',
transform: 'translateY(-6px)',
},
})
}
if (ensFailure) {
return h('i.fa.fa-warning.fa-lg.warning')
}
if (ensResolution && (ensResolution !== ZERO_ADDRESS)) {
return h('i.fa.fa-check-circle.fa-lg.cursor-pointer', {
style: { color: 'green' },
onClick: (event) => {
event.preventDefault()
event.stopPropagation()
copyToClipboard(ensResolution)
},
})
}
}
function getNetworkEnsSupport (network) {
return Boolean(networkMap[network])
}

View File

@ -0,0 +1,79 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import Button from '../../../ui/button/button.component'
export default class AddToAddressBookModal extends Component {
static contextTypes = {
t: PropTypes.func,
}
static propTypes = {
hideModal: PropTypes.func.isRequired,
addToAddressBook: PropTypes.func.isRequired,
recipient: PropTypes.string.isRequired,
}
state = {
alias: '',
}
onSave = () => {
const { recipient, addToAddressBook, hideModal } = this.props
addToAddressBook(recipient, this.state.alias)
hideModal()
}
onChange = e => {
this.setState({
alias: e.target.value,
})
}
onKeyPress = e => {
if (e.keyCode === 13 && this.state.alias) {
this.onSave()
}
}
render () {
const { t } = this.context
return (
<div className="add-to-address-book-modal">
<div className="add-to-address-book-modal__content">
<div className="add-to-address-book-modal__content__header">
{t('addToAddressBook')}
</div>
<div className="add-to-address-book-modal__input-label">
{t('enterAnAlias')}
</div>
<input
type="text"
className="add-to-address-book-modal__input"
placeholder={t('addToAddressBookModalPlaceholder')}
onChange={this.onChange}
onKeyPress={this.onKeyPress}
value={this.state.alias}
autoFocus
/>
</div>
<div className="add-to-address-book-modal__footer">
<Button
type="secondary"
onClick={this.props.hideModal}
>
{t('cancel')}
</Button>
<Button
type="primary"
onClick={this.onSave}
disabled={!this.state.alias}
>
{t('save')}
</Button>
</div>
</div>
)
}
}

View File

@ -0,0 +1,18 @@
import { connect } from 'react-redux'
import AddToAddressBookModal from './add-to-addressbook-modal.component'
import actions from '../../../../store/actions'
function mapStateToProps (state) {
return {
...state.appState.modal.modalState.props || {},
}
}
function mapDispatchToProps (dispatch) {
return {
hideModal: () => dispatch(actions.hideModal()),
addToAddressBook: (recipient, nickname) => dispatch(actions.addToAddressBook(recipient, nickname)),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(AddToAddressBookModal)

View File

@ -0,0 +1 @@
export { default } from './add-to-addressbook-modal.container'

View File

@ -0,0 +1,37 @@
.add-to-address-book-modal {
@extend %col-nowrap;
@extend %modal;
&__content {
@extend %col-nowrap;
padding: 1.5rem;
border-bottom: 1px solid $Grey-100;
&__header {
@extend %h3;
}
}
&__input-label {
color: $Grey-600;
margin-top: 1.25rem;
}
&__input {
@extend %input;
margin-top: 0.75rem;
&::placeholder {
color: $Grey-300;
}
}
&__footer {
@extend %row-nowrap;
padding: 1rem;
button + button {
margin-left: 1rem;
}
}
}

View File

@ -9,3 +9,5 @@
@import 'transaction-confirmed/index';
@import 'metametrics-opt-in-modal/index';
@import './add-to-addressbook-modal/index';

View File

@ -30,6 +30,7 @@ import RejectTransactions from './reject-transactions'
import ClearApprovedOrigins from './clear-approved-origins'
import ConfirmCustomizeGasModal from '../gas-customization/gas-modal-page-container'
import ConfirmDeleteNetwork from './confirm-delete-network'
import AddToAddressBookModal from './add-to-addressbook-modal'
const modalContainerBaseStyle = {
transform: 'translate3d(-50%, 0, 0px)',
@ -167,6 +168,35 @@ const MODALS = {
},
},
ADD_TO_ADDRESSBOOK: {
contents: [
h(AddToAddressBookModal, {}, []),
],
mobileModalStyle: {
width: '95%',
top: '10%',
boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px',
transform: 'none',
left: '0',
right: '0',
margin: '0 auto',
borderRadius: '10px',
},
laptopModalStyle: {
width: '375px',
top: '10%',
boxShadow: 'rgba(0, 0, 0, 0.15) 0px 2px 2px 2px',
transform: 'none',
left: '0',
right: '0',
margin: '0 auto',
borderRadius: '10px',
},
contentStyle: {
borderRadius: '10px',
},
},
ACCOUNT_DETAILS: {
contents: [
h(AccountDetailsModal, {}, []),
@ -466,7 +496,6 @@ module.exports = connect(mapStateToProps, mapDispatchToProps)(Modal)
Modal.prototype.render = function () {
const modal = MODALS[this.props.modalState.name || 'DEFAULT']
const { contents: children, disableBackdropClick = false } = modal
const modalStyle = modal[isMobileView() ? 'mobileModalStyle' : 'laptopModalStyle']
const contentStyle = modal.contentStyle || {}

View File

@ -0,0 +1,26 @@
.dialog {
font-size: .75rem;
line-height: 1rem;
padding: 1rem;
border: 1px solid $black;
box-sizing: border-box;
border-radius: 8px;
&--message {
border-color: $Blue-200;
color: $Blue-600;
background-color: $Blue-000;
}
&--error {
border-color: $Red-300;
color: $Red-600;
background-color: $Red-000;
}
&--warning {
border-color: $Orange-300;
color: $Orange-600;
background-color: $Orange-000;
}
}

View File

@ -0,0 +1,26 @@
import React from 'react'
import PropTypes from 'prop-types'
import c from 'classnames'
export default function Dialog (props) {
const { children, type, className, onClick } = props
return (
<div
className={c('dialog', className, {
'dialog--message': type === 'message',
'dialog--error': type === 'error',
'dialog--warning': type === 'warning',
})}
onClick={onClick}
>
{ children }
</div>
)
}
Dialog.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
type: PropTypes.oneOf(['message', 'error', 'warning']),
onClick: PropTypes.func,
}

View File

@ -1,6 +1,6 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames'
import c from 'classnames'
export default class PageContainerHeader extends Component {
static propTypes = {
@ -13,6 +13,7 @@ export default class PageContainerHeader extends Component {
backButtonString: PropTypes.string,
tabs: PropTypes.node,
headerCloseText: PropTypes.string,
className: PropTypes.string,
}
renderTabs () {
@ -42,15 +43,14 @@ export default class PageContainerHeader extends Component {
}
render () {
const { title, subtitle, onClose, tabs, headerCloseText } = this.props
const { title, subtitle, onClose, tabs, headerCloseText, className } = this.props
return (
<div className={
classnames(
'page-container__header',
{ 'page-container__header--no-padding-bottom': Boolean(tabs) }
)
}>
<div
className={c('page-container__header', className, {
'page-container__header--no-padding-bottom': Boolean(tabs),
})}
>
{ this.renderHeaderRow() }

View File

@ -61,6 +61,9 @@ const styles = {
...inputLabelBase,
fontSize: '.75rem',
},
inputMultiline: {
lineHeight: 'initial !important',
},
}
const TextField = props => {

View File

@ -1,4 +1,5 @@
@import '../../../components/ui/button/buttons';
@import '../../../components/ui/dialog/dialog';
@import './footer.scss';

View File

@ -536,8 +536,6 @@
}
&__form {
padding: 10px 0 25px;
@media screen and (max-width: $break-small) {
margin: 0;
flex: 1 1 auto;
@ -553,7 +551,7 @@
}
&__form-row {
margin: 8px 18px 0px;
margin: 1rem 1rem 0px;
position: relative;
display: flex;
flex-flow: row;
@ -570,7 +568,6 @@
&__form-field {
flex: 1 1 auto;
min-width: 0;
max-width: 277px;
.currency-display {
color: $tundora;
@ -758,16 +755,8 @@
&__to-autocomplete {
position: relative;
&__down-caret {
z-index: 1026;
position: absolute;
top: 18px;
right: 12px;
}
&__qr-code {
z-index: 1026;
position: absolute;
top: 13px;
right: 33px;
cursor: pointer;
@ -778,13 +767,52 @@
&__qr-code:hover {
background: #f1f1f1;
}
}
&__input.with-qr {
padding-right: 65px;
&__to-autocomplete {
display: flex;
flex-direction: row;
z-index: 1025;
position: relative;
height: 54px;
width: 100%;
border: 1px solid $Grey-100;
border-radius: 8px;
background-color: $white;
color: $tundora;
padding: 0 10px;
font-family: Roboto;
line-height: 21px;
align-items: center;
&__input {
font-size: 16px;
height: 100%;
border: none;
flex: 1 1 auto;
width: 0;
&::placeholder {
color: #A1A5B3;
}
}
&__resolved {
font-size: 12px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
height: 30px;
cursor: pointer;
+ .send-v2__to-autocomplete__qr-code {
top: 2px;
right: 0;
}
}
}
&__to-autocomplete, &__memo-text-area, &__hex-data {
&__memo-text-area, &__hex-data {
&__input {
z-index: 1025;
position: relative;

View File

@ -74,6 +74,36 @@ $send-card-z-index: 20;
$sidebar-z-index: 26;
$sidebar-overlay-z-index: 25;
// Flex
%row-nowrap {
display: flex;
flex-flow: row nowrap;
}
%col-nowrap {
display: flex;
flex-flow: column nowrap;
}
// Background Image Sizing
%bg-contain {
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
%ellipsify {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
%modal {
background-color: $white;
border-radius: 10px;
box-shadow: 0px 5px 16px rgba($black, 0.25);;
}
/*
Z Indicies - Current
app - 11
@ -94,24 +124,73 @@ $break-large: 576px;
$primary-font-type: Roboto;
$Blue-000: #eaf6ff;
$Blue-100: #a7d9fe;
$Blue-200: #75c4fd;
$Blue-300: #43aefc;
$Blue-400: #1098fc;
$Blue-500: #037DD6;
$Blue-600: #0260a4;
$Blue-700: #024272;
$Blue-800: #01253f;
$Blue-900: #00080d;
$Grey-000: #f2f3f4;
$Grey-100: #D6D9DC;
$Grey-200: #bbc0c5;
$Grey-300: #9fa6ae;
$Grey-400: #848c96;
$Grey-200: #bbc0c5;
$Grey-500: #6A737D;
$Grey-600: #535a61;
$Grey-800: #24272a;
$Red-000: #fcf2f3;
$Red-100: #f7d5d8;
$Red-200: #f1b9be;
$Red-300: #e88f97;
$Red-400: #e06470;
$Red-500: #D73A49;
$Red-600: #b92534;
$Red-700: #8e1d28;
$Red-800: #64141c;
$Red-900: #3a0c10;
$Orange-000: #fef5ef;
$Orange-300: #faa66c;
$Orange-600: #c65507;
$Orange-500: #F66A0A;
// Font Sizes
%h3 {
font-size: 1.5rem;
line-height: 2.125rem;
font-weight: 400;
}
%h4 {
font-size: 1.125rem;
line-height: 1.3125rem;
font-weight: 400;
}
%h5 {
font-size: 1rem;
line-height: 1.25rem;
font-weight: 400;
}
%h6 {
font-size: .875rem;
line-height: 1.25rem;
font-weight: 400;
}
%h8 {
font-size: .75rem;
line-height: 1.0625rem;
font-weight: 400;
}
/*
Spacing Variables
@ -127,3 +206,24 @@ $xlarge-spacing: 48px;
$xxlarge-spacing: 64px;
%input {
background: $white;
border: 1px solid $Grey-100;
box-sizing: border-box;
border-radius: 8px;
padding: .625rem .75rem;
font-size: 1.25rem;
}
// Input mixin
%input-2 {
border: 2px solid $Grey-200;
border-radius: 6px;
color: $Grey-800;
padding: 0.875rem 1rem;
font-size: 1.125rem;
&:focus-within {
border-color: $Blue-500;
}
}

View File

@ -39,6 +39,8 @@ function reduceMetamask (state, action) {
editingTransactionId: null,
forceGasMin: null,
toNickname: '',
ensResolution: null,
ensResolutionError: '',
},
coinOptions: {},
useBlockie: false,
@ -273,6 +275,24 @@ function reduceMetamask (state, action) {
},
})
case actions.UPDATE_SEND_ENS_RESOLUTION:
return extend(metamaskState, {
send: {
...metamaskState.send,
ensResolution: action.payload,
ensResolutionError: '',
},
})
case actions.UPDATE_SEND_ENS_RESOLUTION_ERROR:
return extend(metamaskState, {
send: {
...metamaskState.send,
ensResolution: null,
ensResolutionError: action.payload,
},
})
case actions.CLEAR_SEND:
return extend(metamaskState, {
send: {

View File

@ -7,6 +7,13 @@ const ADVANCED_ROUTE = '/settings/advanced'
const SECURITY_ROUTE = '/settings/security'
const ABOUT_US_ROUTE = '/settings/about-us'
const NETWORKS_ROUTE = '/settings/networks'
const CONTACT_LIST_ROUTE = '/settings/contact-list'
const CONTACT_EDIT_ROUTE = '/settings/contact-list/edit-contact'
const CONTACT_ADD_ROUTE = '/settings/contact-list/add-contact'
const CONTACT_VIEW_ROUTE = '/settings/contact-list/view-contact'
const CONTACT_MY_ACCOUNTS_ROUTE = '/settings/contact-list/my-accounts'
const CONTACT_MY_ACCOUNTS_VIEW_ROUTE = '/settings/contact-list/my-accounts/view'
const CONTACT_MY_ACCOUNTS_EDIT_ROUTE = '/settings/contact-list/my-accounts/edit'
const REVEAL_SEED_ROUTE = '/seed'
const MOBILE_SYNC_ROUTE = '/mobile-sync'
const RESTORE_VAULT_ROUTE = '/restore-vault'
@ -75,5 +82,12 @@ module.exports = {
SECURITY_ROUTE,
GENERAL_ROUTE,
ABOUT_US_ROUTE,
CONTACT_LIST_ROUTE,
CONTACT_EDIT_ROUTE,
CONTACT_ADD_ROUTE,
CONTACT_VIEW_ROUTE,
CONTACT_MY_ACCOUNTS_ROUTE,
CONTACT_MY_ACCOUNTS_VIEW_ROUTE,
CONTACT_MY_ACCOUNTS_EDIT_ROUTE,
NETWORKS_ROUTE,
}

View File

@ -2,6 +2,8 @@
@import 'add-token/index';
@import 'send/send';
@import 'confirm-add-token/index';
@import 'settings/index';

View File

@ -0,0 +1,243 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import Fuse from 'fuse.js'
import Identicon from '../../../../components/ui/identicon'
import {isValidAddress} from '../../../../helpers/utils/util'
import Dialog from '../../../../components/ui/dialog'
import ContactList from '../../../../components/app/contact-list'
import RecipientGroup from '../../../../components/app/contact-list/recipient-group/recipient-group.component'
import {ellipsify} from '../../send.utils'
export default class AddRecipient extends Component {
static propTypes = {
className: PropTypes.string,
query: PropTypes.string,
ownedAccounts: PropTypes.array,
addressBook: PropTypes.array,
updateGas: PropTypes.func,
updateSendTo: PropTypes.func,
ensResolution: PropTypes.string,
toError: PropTypes.string,
toWarning: PropTypes.string,
ensResolutionError: PropTypes.string,
selectedToken: PropTypes.object,
hasHexData: PropTypes.bool,
tokens: PropTypes.array,
addressBookEntryName: PropTypes.string,
contacts: PropTypes.array,
nonContacts: PropTypes.array,
}
constructor (props) {
super(props)
this.recentFuse = new Fuse(props.nonContacts, {
shouldSort: true,
threshold: 0.45,
location: 0,
distance: 100,
maxPatternLength: 32,
minMatchCharLength: 1,
keys: [
{ name: 'address', weight: 0.5 },
],
})
this.contactFuse = new Fuse(props.contacts, {
shouldSort: true,
threshold: 0.45,
location: 0,
distance: 100,
maxPatternLength: 32,
minMatchCharLength: 1,
keys: [
{ name: 'name', weight: 0.5 },
{ name: 'address', weight: 0.5 },
],
})
}
static contextTypes = {
t: PropTypes.func,
metricsEvent: PropTypes.func,
}
state = {
isShowingTransfer: false,
isShowingAllRecent: false,
}
selectRecipient = (to, nickname = '') => {
const { updateSendTo, updateGas } = this.props
updateSendTo(to, nickname)
updateGas({ to })
}
searchForContacts = () => {
const { query, contacts } = this.props
let _contacts = contacts
if (query) {
this.contactFuse.setCollection(contacts)
_contacts = this.contactFuse.search(query)
}
return _contacts
}
searchForRecents = () => {
const { query, nonContacts } = this.props
let _nonContacts = nonContacts
if (query) {
this.recentFuse.setCollection(nonContacts)
_nonContacts = this.recentFuse.search(query)
}
return _nonContacts
}
render () {
const { ensResolution, query, addressBookEntryName } = this.props
const { isShowingTransfer } = this.state
let content
if (isValidAddress(query)) {
content = this.renderExplicitAddress(query)
} else if (ensResolution) {
content = this.renderExplicitAddress(ensResolution, addressBookEntryName || query)
} else if (isShowingTransfer) {
content = this.renderTransfer()
}
return (
<div className="send__select-recipient-wrapper">
{ this.renderDialogs() }
{ content || this.renderMain() }
</div>
)
}
renderExplicitAddress (address, name) {
return (
<div
key={address}
className="send__select-recipient-wrapper__group-item"
onClick={() => this.selectRecipient(address, name)}
>
<Identicon address={address} diameter={28} />
<div className="send__select-recipient-wrapper__group-item__content">
<div className="send__select-recipient-wrapper__group-item__title">
{name || ellipsify(address)}
</div>
{
name && (
<div className="send__select-recipient-wrapper__group-item__subtitle">
{ellipsify(address)}
</div>
)
}
</div>
</div>
)
}
renderTransfer () {
const { ownedAccounts } = this.props
const { t } = this.context
return (
<div className="send__select-recipient-wrapper__list">
<div
className="send__select-recipient-wrapper__list__link"
onClick={() => this.setState({ isShowingTransfer: false })}
>
<div className="send__select-recipient-wrapper__list__back-caret"/>
{ t('backToAll') }
</div>
<RecipientGroup
label={t('myAccounts')}
items={ownedAccounts}
onSelect={this.selectRecipient}
/>
</div>
)
}
renderMain () {
const { t } = this.context
const { query, ownedAccounts = [], addressBook } = this.props
return (
<div className="send__select-recipient-wrapper__list">
<ContactList
addressBook={addressBook}
searchForContacts={this.searchForContacts.bind(this)}
searchForRecents={this.searchForRecents.bind(this)}
selectRecipient={this.selectRecipient.bind(this)}
>
{
(ownedAccounts && ownedAccounts.length > 1) && !query && (
<div
className="send__select-recipient-wrapper__list__link"
onClick={() => this.setState({ isShowingTransfer: true })}
>
{ t('transferBetweenAccounts') }
</div>
)
}
</ContactList>
</div>
)
}
renderDialogs () {
const { toError, toWarning, ensResolutionError, ensResolution } = this.props
const { t } = this.context
const contacts = this.searchForContacts()
const recents = this.searchForRecents()
if (contacts.length || recents.length) {
return null
}
if (ensResolutionError) {
return (
<Dialog
type="error"
className="send__error-dialog"
>
{ensResolutionError}
</Dialog>
)
}
if (toError && toError !== 'required' && !ensResolution) {
return (
<Dialog
type="error"
className="send__error-dialog"
>
{t(toError)}
</Dialog>
)
}
if (toWarning) {
return (
<Dialog
type="warning"
className="send__error-dialog"
>
{t(toWarning)}
</Dialog>
)
}
}
}

View File

@ -0,0 +1,44 @@
import { connect } from 'react-redux'
import {
accountsWithSendEtherInfoSelector,
getSendEnsResolution,
getSendEnsResolutionError,
} from '../../send.selectors.js'
import {
getAddressBook,
getAddressBookEntry,
} from '../../../../selectors/selectors'
import {
updateSendTo,
} from '../../../../store/actions'
import AddRecipient from './add-recipient.component'
export default connect(mapStateToProps, mapDispatchToProps)(AddRecipient)
function mapStateToProps (state) {
const ensResolution = getSendEnsResolution(state)
let addressBookEntryName = ''
if (ensResolution) {
const addressBookEntry = getAddressBookEntry(state, ensResolution) || {}
addressBookEntryName = addressBookEntry.name
}
const addressBook = getAddressBook(state)
return {
ownedAccounts: accountsWithSendEtherInfoSelector(state),
addressBook,
ensResolution,
addressBookEntryName,
ensResolutionError: getSendEnsResolutionError(state),
contacts: addressBook.filter(({ name }) => !!name),
nonContacts: addressBook.filter(({ name }) => !name),
}
}
function mapDispatchToProps (dispatch) {
return {
updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)),
}
}

View File

@ -0,0 +1,268 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import c from 'classnames'
import { isValidENSAddress, isValidAddress } from '../../../../helpers/utils/util'
import {ellipsify} from '../../send.utils'
import debounce from 'debounce'
import copyToClipboard from 'copy-to-clipboard/index'
import ENS from 'ethjs-ens'
import networkMap from 'ethjs-ens/lib/network-map.json'
import log from 'loglevel'
// Local Constants
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
const ZERO_X_ERROR_ADDRESS = '0x'
export default class EnsInput extends Component {
static contextTypes = {
t: PropTypes.func,
}
static propTypes = {
className: PropTypes.string,
network: PropTypes.string,
selectedAddress: PropTypes.string,
selectedName: PropTypes.string,
onChange: PropTypes.func,
updateSendTo: PropTypes.func,
updateEnsResolution: PropTypes.func,
scanQrCode: PropTypes.func,
updateEnsResolutionError: PropTypes.func,
addressBook: PropTypes.array,
onPaste: PropTypes.func,
onReset: PropTypes.func,
}
state = {
recipient: null,
input: '',
toError: null,
toWarning: null,
}
componentDidMount () {
const network = this.props.network
const networkHasEnsSupport = getNetworkEnsSupport(network)
this.setState({ ensResolution: ZERO_ADDRESS })
if (networkHasEnsSupport) {
const provider = global.ethereumProvider
this.ens = new ENS({ provider, network })
this.checkName = debounce(this.lookupEnsName, 200)
}
}
// If an address is sent without a nickname, meaning not from ENS or from
// the user's own accounts, a default of a one-space string is used.
componentDidUpdate (prevProps) {
const {
input,
} = this.state
const {
network,
} = this.props
if (prevProps.network !== network) {
const provider = global.ethereumProvider
this.ens = new ENS({ provider, network })
this.onChange({ target: { value: input } })
}
}
resetInput = () => {
const { updateEnsResolution, updateEnsResolutionError, onReset } = this.props
this.onChange({ target: { value: '' } })
onReset()
updateEnsResolution('')
updateEnsResolutionError('')
}
lookupEnsName = (recipient) => {
recipient = recipient.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 (isValidENSAddress(recipient) && reason.message === 'ENS name not defined.') {
this.props.updateEnsResolutionError(this.context.t('ensNotFoundOnCurrentNetwork'))
} else {
log.error(reason)
this.props.updateEnsResolutionError(reason.message)
}
})
}
onPaste = event => {
event.clipboardData.items[0].getAsString(text => {
if (isValidAddress(text)) {
this.props.onPaste(text)
}
})
}
onChange = e => {
const { network, onChange, updateEnsResolution, updateEnsResolutionError } = this.props
const input = e.target.value
const networkHasEnsSupport = getNetworkEnsSupport(network)
this.setState({ input }, () => onChange(input))
// Empty ENS state if input is empty
// maybe scan ENS
if (!input || isValidAddress(input) || !networkHasEnsSupport) {
updateEnsResolution('')
updateEnsResolutionError(!networkHasEnsSupport ? 'Network does not support ENS' : '')
return
}
if (isValidENSAddress(input)) {
this.lookupEnsName(input)
} else {
updateEnsResolution('')
updateEnsResolutionError('')
}
}
render () {
const { t } = this.context
const { className, selectedAddress } = this.props
const { input } = this.state
if (selectedAddress) {
return this.renderSelected()
}
return (
<div className={c('ens-input', className)}>
<div
className={c('ens-input__wrapper', {
'ens-input__wrapper__status-icon--error': false,
'ens-input__wrapper__status-icon--valid': false,
})}
>
<div className="ens-input__wrapper__status-icon" />
<input
className="ens-input__wrapper__input"
type="text"
placeholder={t('recipientAddressPlaceholder')}
onChange={this.onChange}
onPaste={this.onPaste}
value={selectedAddress || input}
autoFocus
/>
<div
className={c('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, addressBook } = this.props
const contact = addressBook.filter(item => item.address === selectedAddress)[0] || {}
const name = contact.name || selectedName
return (
<div className={c('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}
>
<div className="ens-input__selected-input__title">
{name || ellipsify(selectedAddress)}
</div>
{ name && <div className="ens-input__selected-input__subtitle">{selectedAddress}</div> }
</div>
<div
className="ens-input__wrapper__action-icon ens-input__wrapper__action-icon--erase"
onClick={this.resetInput}
/>
</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 || { ensResolution: ZERO_ADDRESS }
if (toError) return
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)
}}
/>
)
}
}
}
function getNetworkEnsSupport (network) {
return Boolean(networkMap[network])
}

View File

@ -0,0 +1,20 @@
import EnsInput from './ens-input.component'
import {
getCurrentNetwork,
getSendTo,
getSendToNickname,
} from '../../send.selectors'
import {
getAddressBook,
} from '../../../../selectors/selectors'
const connect = require('react-redux').connect
export default connect(
state => ({
network: getCurrentNetwork(state),
selectedAddress: getSendTo(state),
selectedName: getSendToNickname(state),
addressBook: getAddressBook(state),
})
)(EnsInput)

View File

@ -0,0 +1 @@
export { default } from './ens-input.container'

View File

@ -0,0 +1 @@
export { default } from './add-recipient.container'

View File

@ -0,0 +1,202 @@
import React from 'react'
import assert from 'assert'
import { shallow } from 'enzyme'
import sinon from 'sinon'
import AddRecipient from '../add-recipient.component'
import Dialog from '../../../../../components/ui/dialog'
const propsMethodSpies = {
closeToDropdown: sinon.spy(),
openToDropdown: sinon.spy(),
updateGas: sinon.spy(),
updateSendTo: sinon.spy(),
updateSendToError: sinon.spy(),
updateSendToWarning: sinon.spy(),
}
describe('AddRecipient Component', function () {
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}
updateSendTo={propsMethodSpies.updateSendTo}
updateSendToError={propsMethodSpies.updateSendToError}
updateSendToWarning={propsMethodSpies.updateSendToWarning}
addressBook={[{ address: '0x80F061544cC398520615B5d3e7A3BedD70cd4510', name: 'Fav 5' }]}
nonContacts={[{ address: '0x70F061544cC398520615B5d3e7A3BedD70cd4510', name: 'Fav 7' }]}
contacts={[{ address: '0x60F061544cC398520615B5d3e7A3BedD70cd4510', name: 'Fav 6' }]}
/>, { 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', () => {
assert.equal(propsMethodSpies.updateSendTo.callCount, 0)
instance.selectRecipient('mockTo2', 'mockNickname')
assert.equal(propsMethodSpies.updateSendTo.callCount, 1)
assert.deepEqual(
propsMethodSpies.updateSendTo.getCall(0).args,
['mockTo2', 'mockNickname']
)
})
it('should call updateGas if there is no to error', () => {
assert.equal(propsMethodSpies.updateGas.callCount, 0)
instance.selectRecipient(false)
assert.equal(propsMethodSpies.updateGas.callCount, 1)
})
})
describe('render', () => {
it('should render a component', () => {
assert.equal(wrapper.find('.send__select-recipient-wrapper').length, 1)
})
it('should render no content if there are no recents, transfers, and contacts', () => {
wrapper.setProps({
ownedAccounts: [],
addressBook: [],
})
assert.equal(wrapper.find('.send__select-recipient-wrapper__list__link').length, 0)
assert.equal(wrapper.find('.send__select-recipient-wrapper__group').length, 0)
})
it('should render transfer', () => {
wrapper.setProps({
ownedAccounts: [{ address: '0x123', name: '123' }, { address: '0x124', name: '124' }],
addressBook: [{ address: '0x456', name: 'test-name' }],
})
wrapper.setState({ isShowingTransfer: true })
const xferLink = wrapper.find('.send__select-recipient-wrapper__list__link')
assert.equal(xferLink.length, 1)
const groups = wrapper.find('RecipientGroup')
assert.equal(groups.shallow().find('.send__select-recipient-wrapper__group').length, 1)
})
it('should render ContactList', () => {
wrapper.setProps({
ownedAccounts: [{ address: '0x123', name: '123' }, { address: '0x124', name: '124' }],
addressBook: [{ address: '0x125' }],
})
const contactList = wrapper.find('ContactList')
assert.equal(contactList.length, 1)
})
it('should render contacts', () => {
wrapper.setProps({
addressBook: [
{ address: '0x125', name: 'alice' },
{ address: '0x126', name: 'alex' },
{ address: '0x127', name: 'catherine' },
],
})
wrapper.setState({ isShowingTransfer: false })
const xferLink = wrapper.find('.send__select-recipient-wrapper__list__link')
assert.equal(xferLink.length, 0)
const groups = wrapper.find('ContactList')
assert.equal(groups.length, 1)
assert.equal(groups.find('.send__select-recipient-wrapper__group-item').length, 0)
})
it('should render error when query has no results', () => {
wrapper.setProps({
addressBook: [],
toError: 'bad',
contacts: [],
nonContacts: [],
})
const dialog = wrapper.find(Dialog)
assert.equal(dialog.props().type, 'error')
assert.equal(dialog.props().children, 'bad_t')
assert.equal(dialog.length, 1)
})
it('should render error when query has ens does not resolve', () => {
wrapper.setProps({
addressBook: [],
toError: 'bad',
ensResolutionError: 'very bad',
contacts: [],
nonContacts: [],
})
const dialog = wrapper.find(Dialog)
assert.equal(dialog.props().type, 'error')
assert.equal(dialog.props().children, 'very bad')
assert.equal(dialog.length, 1)
})
it('should render warning', () => {
wrapper.setProps({
addressBook: [],
query: 'yo',
toWarning: 'watchout',
})
const dialog = wrapper.find(Dialog)
assert.equal(dialog.props().type, 'warning')
assert.equal(dialog.props().children, 'watchout_t')
assert.equal(dialog.length, 1)
})
it('should not render error when ens resolved', () => {
wrapper.setProps({
addressBook: [],
toError: 'bad',
ensResolution: '0x128',
})
const dialog = wrapper.find(Dialog)
assert.equal(dialog.length, 0)
})
it('should not render error when query has results', () => {
wrapper.setProps({
addressBook: [
{ address: '0x125', name: 'alice' },
{ address: '0x126', name: 'alex' },
{ address: '0x127', name: 'catherine' },
],
toError: 'bad',
})
const dialog = wrapper.find(Dialog)
assert.equal(dialog.length, 0)
})
})
})

View File

@ -0,0 +1,72 @@
import assert from 'assert'
import proxyquire from 'proxyquire'
import sinon from 'sinon'
let mapStateToProps
let mapDispatchToProps
const actionSpies = {
updateSendTo: sinon.spy(),
}
proxyquire('../add-recipient.container.js', {
'react-redux': {
connect: (ms, md) => {
mapStateToProps = ms
mapDispatchToProps = md
return () => ({})
},
},
'../../send.selectors.js': {
getSendEnsResolution: (s) => `mockSendEnsResolution:${s}`,
getSendEnsResolutionError: (s) => `mockSendEnsResolutionError:${s}`,
accountsWithSendEtherInfoSelector: (s) => `mockAccountsWithSendEtherInfoSelector:${s}`,
},
'../../../../selectors/selectors': {
getAddressBook: (s) => [{ name: `mockAddressBook:${s}` }],
getAddressBookEntry: (s) => `mockAddressBookEntry:${s}`,
},
'../../../../store/actions': actionSpies,
})
describe('add-recipient container', () => {
describe('mapStateToProps()', () => {
it('should map the correct properties to props', () => {
assert.deepEqual(mapStateToProps('mockState'), {
addressBook: [{ name: 'mockAddressBook:mockState' }],
contacts: [{ name: 'mockAddressBook:mockState' }],
ensResolution: 'mockSendEnsResolution:mockState',
ensResolutionError: 'mockSendEnsResolutionError:mockState',
ownedAccounts: 'mockAccountsWithSendEtherInfoSelector:mockState',
addressBookEntryName: undefined,
nonContacts: [],
})
})
})
describe('mapDispatchToProps()', () => {
let dispatchSpy
let mapDispatchToPropsObject
beforeEach(() => {
dispatchSpy = sinon.spy()
mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
})
describe('updateSendTo()', () => {
it('should dispatch an action', () => {
mapDispatchToPropsObject.updateSendTo('mockTo', 'mockNickname')
assert(dispatchSpy.calledOnce)
assert(actionSpies.updateSendTo.calledOnce)
assert.deepEqual(
actionSpies.updateSendTo.getCall(0).args,
['mockTo', 'mockNickname']
)
})
})
})
})

View File

@ -3,9 +3,9 @@ import {
getToDropdownOpen,
getTokens,
sendToIsInError,
} from '../send-to-row.selectors.js'
} from '../add-recipient.selectors.js'
describe('send-to-row selectors', () => {
describe('add-recipient selectors', () => {
describe('getToDropdownOpen()', () => {
it('should return send.getToDropdownOpen', () => {

View File

@ -12,7 +12,7 @@ const stubs = {
isValidAddress: sinon.stub().callsFake(to => Boolean(to.match(/^[0xabcdef123456798]+$/))),
}
const toRowUtils = proxyquire('../send-to-row.utils.js', {
const toRowUtils = proxyquire('../add-recipient.js', {
'../../../../helpers/utils/util': {
isValidAddress: stubs.isValidAddress,
},
@ -22,7 +22,7 @@ const {
getToWarningObject,
} = toRowUtils
describe('send-to-row utils', () => {
describe('add-recipient utils', () => {
describe('getToErrorObject()', () => {
it('should return a required error if to is falsy', () => {

View File

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

View File

@ -2,18 +2,25 @@ import React, { Component } from 'react'
import PropTypes from 'prop-types'
import PageContainerContent from '../../../components/ui/page-container/page-container-content.component'
import SendAmountRow from './send-amount-row'
import SendFromRow from './send-from-row'
import SendGasRow from './send-gas-row'
import SendHexDataRow from './send-hex-data-row'
import SendToRow from './send-to-row'
import SendAssetRow from './send-asset-row'
import Dialog from '../../../components/ui/dialog'
export default class SendContent extends Component {
static contextTypes = {
t: PropTypes.func,
}
static propTypes = {
updateGas: PropTypes.func,
scanQrCode: PropTypes.func,
showAddToAddressBookModal: PropTypes.func,
showHexData: PropTypes.bool,
to: PropTypes.string,
ownedAccounts: PropTypes.array,
addressBook: PropTypes.array,
}
updateGas = (updateData) => this.props.updateGas(updateData)
@ -22,22 +29,40 @@ export default class SendContent extends Component {
return (
<PageContainerContent>
<div className="send-v2__form">
<SendFromRow />
<SendToRow
updateGas={this.updateGas}
scanQrCode={ _ => this.props.scanQrCode()}
/>
{ this.maybeRenderAddContact() }
<SendAssetRow />
<SendAmountRow updateGas={this.updateGas} />
<SendGasRow />
{(this.props.showHexData && (
<SendHexDataRow
updateGas={this.updateGas}
/>
))}
{
this.props.showHexData && (
<SendHexDataRow
updateGas={this.updateGas}
/>
)
}
</div>
</PageContainerContent>
)
}
maybeRenderAddContact () {
const { t } = this.context
const { to, addressBook = [], ownedAccounts = [], showAddToAddressBookModal } = this.props
const isOwnedAccount = !!ownedAccounts.find(({ address }) => address === to)
const contact = addressBook.find(({ address }) => address === to) || {}
if (isOwnedAccount || contact.name) {
return
}
return (
<Dialog
type="message"
className="send__dialog"
onClick={showAddToAddressBookModal}
>
{t('newAccountDetectedDialogMessage')}
</Dialog>
)
}
}

View File

@ -0,0 +1,38 @@
import { connect } from 'react-redux'
import SendContent from './send-content.component'
import {
accountsWithSendEtherInfoSelector,
getSendTo,
} from '../send.selectors'
import {
getAddressBook,
} from '../../../selectors/selectors'
import actions from '../../../store/actions'
function mapStateToProps (state) {
return {
to: getSendTo(state),
addressBook: getAddressBook(state),
ownedAccounts: accountsWithSendEtherInfoSelector(state),
}
}
function mapDispatchToProps (dispatch) {
return {
showAddToAddressBookModal: (recipient) => dispatch(actions.showModal({
name: 'ADD_TO_ADDRESSBOOK',
recipient,
})),
}
}
function mergeProps (stateProps, dispatchProps, ownProps) {
return {
...ownProps,
...stateProps,
...dispatchProps,
showAddToAddressBookModal: () => dispatchProps.showAddToAddressBookModal(stateProps.to),
}
}
export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(SendContent)

View File

@ -1 +0,0 @@
export { default } from './send-to-row.container'

View File

@ -1,91 +0,0 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import SendRowWrapper from '../send-row-wrapper'
import EnsInput from '../../../../components/app/ens-input'
import { getToErrorObject, getToWarningObject } from './send-to-row.utils.js'
export default class SendToRow extends Component {
static propTypes = {
closeToDropdown: PropTypes.func,
hasHexData: PropTypes.bool.isRequired,
inError: PropTypes.bool,
inWarning: PropTypes.bool,
network: PropTypes.string,
openToDropdown: PropTypes.func,
selectedToken: PropTypes.object,
to: PropTypes.string,
toAccounts: PropTypes.array,
toDropdownOpen: PropTypes.bool,
tokens: PropTypes.array,
updateGas: PropTypes.func,
updateSendTo: PropTypes.func,
updateSendToError: PropTypes.func,
updateSendToWarning: PropTypes.func,
scanQrCode: PropTypes.func,
}
static contextTypes = {
t: PropTypes.func,
metricsEvent: PropTypes.func,
}
handleToChange (to, nickname = '', toError, toWarning, network) {
const { hasHexData, updateSendTo, updateSendToError, updateGas, tokens, selectedToken, updateSendToWarning } = this.props
const toErrorObject = getToErrorObject(to, toError, hasHexData, tokens, selectedToken, network)
const toWarningObject = getToWarningObject(to, toWarning, tokens, selectedToken)
updateSendTo(to, nickname)
updateSendToError(toErrorObject)
updateSendToWarning(toWarningObject)
if (toErrorObject.to === null) {
updateGas({ to })
}
}
render () {
const {
closeToDropdown,
inError,
inWarning,
network,
openToDropdown,
to,
toAccounts,
toDropdownOpen,
} = this.props
return (
<SendRowWrapper
errorType={'to'}
label={`${this.context.t('to')}: `}
showError={inError}
showWarning={inWarning}
warningType={'to'}
>
<EnsInput
scanQrCode={_ => {
this.context.metricsEvent({
eventOpts: {
category: 'Transactions',
action: 'Edit Screen',
name: 'Used QR scanner',
},
})
this.props.scanQrCode()
}}
accounts={toAccounts}
closeDropdown={() => closeToDropdown()}
dropdownOpen={toDropdownOpen}
inError={inError}
name={'address'}
network={network}
onChange={({ toAddress, nickname, toError, toWarning }) => this.handleToChange(toAddress, nickname, toError, toWarning, this.props.network)}
openDropdown={() => openToDropdown()}
placeholder={this.context.t('recipientAddress')}
to={to}
/>
</SendRowWrapper>
)
}
}

View File

@ -1,54 +0,0 @@
import { connect } from 'react-redux'
import {
getCurrentNetwork,
getSelectedToken,
getSendTo,
getSendToAccounts,
getSendHexData,
} from '../../send.selectors.js'
import {
getToDropdownOpen,
getTokens,
sendToIsInError,
sendToIsInWarning,
} from './send-to-row.selectors.js'
import {
updateSendTo,
} from '../../../../store/actions'
import {
updateSendErrors,
updateSendWarnings,
openToDropdown,
closeToDropdown,
} from '../../../../ducks/send/send.duck'
import SendToRow from './send-to-row.component'
export default connect(mapStateToProps, mapDispatchToProps)(SendToRow)
function mapStateToProps (state) {
return {
hasHexData: Boolean(getSendHexData(state)),
inError: sendToIsInError(state),
inWarning: sendToIsInWarning(state),
network: getCurrentNetwork(state),
selectedToken: getSelectedToken(state),
to: getSendTo(state),
toAccounts: getSendToAccounts(state),
toDropdownOpen: getToDropdownOpen(state),
tokens: getTokens(state),
}
}
function mapDispatchToProps (dispatch) {
return {
closeToDropdown: () => dispatch(closeToDropdown()),
openToDropdown: () => dispatch(openToDropdown()),
updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)),
updateSendToError: (toErrorObject) => {
dispatch(updateSendErrors(toErrorObject))
},
updateSendToWarning: (toWarningObject) => {
dispatch(updateSendWarnings(toWarningObject))
},
}
}

View File

@ -1,166 +0,0 @@
import React from 'react'
import assert from 'assert'
import { shallow } from 'enzyme'
import sinon from 'sinon'
import proxyquire from 'proxyquire'
const SendToRow = proxyquire('../send-to-row.component.js', {
'./send-to-row.utils.js': {
getToErrorObject: (to, toError) => ({
to: to === false ? null : `mockToErrorObject:${to}${toError}`,
}),
getToWarningObject: (to, toWarning) => ({
to: to === false ? null : `mockToWarningObject:${to}${toWarning}`,
}),
},
}).default
import SendRowWrapper from '../../send-row-wrapper/send-row-wrapper.component'
import EnsInput from '../../../../../components/app/ens-input'
const propsMethodSpies = {
closeToDropdown: sinon.spy(),
openToDropdown: sinon.spy(),
updateGas: sinon.spy(),
updateSendTo: sinon.spy(),
updateSendToError: sinon.spy(),
updateSendToWarning: sinon.spy(),
}
sinon.spy(SendToRow.prototype, 'handleToChange')
describe('SendToRow Component', function () {
let wrapper
let instance
beforeEach(() => {
wrapper = shallow(<SendToRow
closeToDropdown={propsMethodSpies.closeToDropdown}
inError={false}
inWarning={false}
network={'mockNetwork'}
openToDropdown={propsMethodSpies.openToDropdown}
to={'mockTo'}
toAccounts={['mockAccount']}
toDropdownOpen={false}
updateGas={propsMethodSpies.updateGas}
updateSendTo={propsMethodSpies.updateSendTo}
updateSendToError={propsMethodSpies.updateSendToError}
updateSendToWarning={propsMethodSpies.updateSendToWarning}
/>, { context: { t: str => str + '_t' } })
instance = wrapper.instance()
})
afterEach(() => {
propsMethodSpies.closeToDropdown.resetHistory()
propsMethodSpies.openToDropdown.resetHistory()
propsMethodSpies.updateSendTo.resetHistory()
propsMethodSpies.updateSendToError.resetHistory()
propsMethodSpies.updateSendToWarning.resetHistory()
SendToRow.prototype.handleToChange.resetHistory()
})
describe('handleToChange', () => {
it('should call updateSendTo', () => {
assert.equal(propsMethodSpies.updateSendTo.callCount, 0)
instance.handleToChange('mockTo2', 'mockNickname')
assert.equal(propsMethodSpies.updateSendTo.callCount, 1)
assert.deepEqual(
propsMethodSpies.updateSendTo.getCall(0).args,
['mockTo2', 'mockNickname']
)
})
it('should call updateSendToError', () => {
assert.equal(propsMethodSpies.updateSendToError.callCount, 0)
instance.handleToChange('mockTo2', '', 'mockToError')
assert.equal(propsMethodSpies.updateSendToError.callCount, 1)
assert.deepEqual(
propsMethodSpies.updateSendToError.getCall(0).args,
[{ to: 'mockToErrorObject:mockTo2mockToError' }]
)
})
it('should call updateSendToWarning', () => {
assert.equal(propsMethodSpies.updateSendToWarning.callCount, 0)
instance.handleToChange('mockTo2', '', '', 'mockToWarning')
assert.equal(propsMethodSpies.updateSendToWarning.callCount, 1)
assert.deepEqual(
propsMethodSpies.updateSendToWarning.getCall(0).args,
[{ to: 'mockToWarningObject:mockTo2mockToWarning' }]
)
})
it('should not call updateGas if there is a to error', () => {
assert.equal(propsMethodSpies.updateGas.callCount, 0)
instance.handleToChange('mockTo2')
assert.equal(propsMethodSpies.updateGas.callCount, 0)
})
it('should call updateGas if there is no to error', () => {
assert.equal(propsMethodSpies.updateGas.callCount, 0)
instance.handleToChange(false)
assert.equal(propsMethodSpies.updateGas.callCount, 1)
})
})
describe('render', () => {
it('should render a SendRowWrapper component', () => {
assert.equal(wrapper.find(SendRowWrapper).length, 1)
})
it('should pass the correct props to SendRowWrapper', () => {
const {
errorType,
label,
showError,
} = wrapper.find(SendRowWrapper).props()
assert.equal(errorType, 'to')
assert.equal(label, 'to_t: ')
assert.equal(showError, false)
})
it('should render an EnsInput as a child of the SendRowWrapper', () => {
assert(wrapper.find(SendRowWrapper).childAt(0).is(EnsInput))
})
it('should render the EnsInput with the correct props', () => {
const {
accounts,
closeDropdown,
dropdownOpen,
inError,
name,
network,
onChange,
openDropdown,
placeholder,
to,
} = wrapper.find(SendRowWrapper).childAt(0).props()
assert.deepEqual(accounts, ['mockAccount'])
assert.equal(dropdownOpen, false)
assert.equal(inError, false)
assert.equal(name, 'address')
assert.equal(network, 'mockNetwork')
assert.equal(placeholder, 'recipientAddress_t')
assert.equal(to, 'mockTo')
assert.equal(propsMethodSpies.closeToDropdown.callCount, 0)
closeDropdown()
assert.equal(propsMethodSpies.closeToDropdown.callCount, 1)
assert.equal(propsMethodSpies.openToDropdown.callCount, 0)
openDropdown()
assert.equal(propsMethodSpies.openToDropdown.callCount, 1)
assert.equal(SendToRow.prototype.handleToChange.callCount, 0)
onChange({ toAddress: 'mockNewTo', nickname: 'mockNewNickname', toError: 'mockToError', toWarning: 'mockToWarning' })
assert.equal(SendToRow.prototype.handleToChange.callCount, 1)
assert.deepEqual(
SendToRow.prototype.handleToChange.getCall(0).args,
['mockNewTo', 'mockNewNickname', 'mockToError', 'mockToWarning', 'mockNetwork' ]
)
})
})
})

View File

@ -1,134 +0,0 @@
import assert from 'assert'
import proxyquire from 'proxyquire'
import sinon from 'sinon'
let mapStateToProps
let mapDispatchToProps
const actionSpies = {
updateSendTo: sinon.spy(),
}
const duckActionSpies = {
closeToDropdown: sinon.spy(),
openToDropdown: sinon.spy(),
updateSendErrors: sinon.spy(),
updateSendWarnings: sinon.spy(),
}
proxyquire('../send-to-row.container.js', {
'react-redux': {
connect: (ms, md) => {
mapStateToProps = ms
mapDispatchToProps = md
return () => ({})
},
},
'../../send.selectors.js': {
getCurrentNetwork: (s) => `mockNetwork:${s}`,
getSelectedToken: (s) => `mockSelectedToken:${s}`,
getSendHexData: (s) => s,
getSendTo: (s) => `mockTo:${s}`,
getSendToAccounts: (s) => `mockToAccounts:${s}`,
},
'./send-to-row.selectors.js': {
getToDropdownOpen: (s) => `mockToDropdownOpen:${s}`,
sendToIsInError: (s) => `mockInError:${s}`,
sendToIsInWarning: (s) => `mockInWarning:${s}`,
getTokens: (s) => `mockTokens:${s}`,
},
'../../../../store/actions': actionSpies,
'../../../../ducks/send/send.duck': duckActionSpies,
})
describe('send-to-row container', () => {
describe('mapStateToProps()', () => {
it('should map the correct properties to props', () => {
assert.deepEqual(mapStateToProps('mockState'), {
hasHexData: true,
inError: 'mockInError:mockState',
inWarning: 'mockInWarning:mockState',
network: 'mockNetwork:mockState',
selectedToken: 'mockSelectedToken:mockState',
to: 'mockTo:mockState',
toAccounts: 'mockToAccounts:mockState',
toDropdownOpen: 'mockToDropdownOpen:mockState',
tokens: 'mockTokens:mockState',
})
})
})
describe('mapDispatchToProps()', () => {
let dispatchSpy
let mapDispatchToPropsObject
beforeEach(() => {
dispatchSpy = sinon.spy()
mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
})
describe('closeToDropdown()', () => {
it('should dispatch an action', () => {
mapDispatchToPropsObject.closeToDropdown()
assert(dispatchSpy.calledOnce)
assert(duckActionSpies.closeToDropdown.calledOnce)
assert.equal(
duckActionSpies.closeToDropdown.getCall(0).args[0],
undefined
)
})
})
describe('openToDropdown()', () => {
it('should dispatch an action', () => {
mapDispatchToPropsObject.openToDropdown()
assert(dispatchSpy.calledOnce)
assert(duckActionSpies.openToDropdown.calledOnce)
assert.equal(
duckActionSpies.openToDropdown.getCall(0).args[0],
undefined
)
})
})
describe('updateSendTo()', () => {
it('should dispatch an action', () => {
mapDispatchToPropsObject.updateSendTo('mockTo', 'mockNickname')
assert(dispatchSpy.calledOnce)
assert(actionSpies.updateSendTo.calledOnce)
assert.deepEqual(
actionSpies.updateSendTo.getCall(0).args,
['mockTo', 'mockNickname']
)
})
})
describe('updateSendToError()', () => {
it('should dispatch an action', () => {
mapDispatchToPropsObject.updateSendToError('mockToErrorObject')
assert(dispatchSpy.calledOnce)
assert(duckActionSpies.updateSendErrors.calledOnce)
assert.equal(
duckActionSpies.updateSendErrors.getCall(0).args[0],
'mockToErrorObject'
)
})
})
describe('updateSendToWarning()', () => {
it('should dispatch an action', () => {
mapDispatchToPropsObject.updateSendToWarning('mockToWarningObject')
assert(dispatchSpy.calledOnce)
assert(duckActionSpies.updateSendWarnings.calledOnce)
assert.equal(
duckActionSpies.updateSendWarnings.getCall(0).args[0],
'mockToWarningObject'
)
})
})
})
})

View File

@ -5,17 +5,21 @@ import SendContent from '../send-content.component.js'
import PageContainerContent from '../../../../components/ui/page-container/page-container-content.component'
import SendAmountRow from '../send-amount-row/send-amount-row.container'
import SendFromRow from '../send-from-row/send-from-row.container'
import SendGasRow from '../send-gas-row/send-gas-row.container'
import SendToRow from '../send-to-row/send-to-row.container'
import SendHexDataRow from '../send-hex-data-row/send-hex-data-row.container'
import SendAssetRow from '../send-asset-row/send-asset-row.container'
import Dialog from '../../../../components/ui/dialog'
describe('SendContent Component', function () {
let wrapper
beforeEach(() => {
wrapper = shallow(<SendContent showHexData={true} />)
wrapper = shallow(
<SendContent
showHexData={true}
/>,
{ context: { t: str => str + '_t' } }
)
})
describe('render', () => {
@ -31,30 +35,55 @@ describe('SendContent Component', function () {
it('should render the correct row components as grandchildren of the PageContainerContent component', () => {
const PageContainerContentChild = wrapper.find(PageContainerContent).children()
assert(PageContainerContentChild.childAt(0).is(SendFromRow))
assert(PageContainerContentChild.childAt(1).is(SendToRow))
assert(PageContainerContentChild.childAt(2).is(SendAssetRow))
assert(PageContainerContentChild.childAt(3).is(SendAmountRow))
assert(PageContainerContentChild.childAt(4).is(SendGasRow))
assert(PageContainerContentChild.childAt(5).is(SendHexDataRow))
assert(PageContainerContentChild.childAt(0).is(Dialog), 'row[0] should be Dialog')
assert(PageContainerContentChild.childAt(1).is(SendAssetRow), 'row[1] should be SendAssetRow')
assert(PageContainerContentChild.childAt(2).is(SendAmountRow), 'row[2] should be SendAmountRow')
assert(PageContainerContentChild.childAt(3).is(SendGasRow), 'row[3] should be SendGasRow')
assert(PageContainerContentChild.childAt(4).is(SendHexDataRow), 'row[4] should be SendHexDataRow')
})
it('should not render the SendHexDataRow if props.showHexData is false', () => {
wrapper.setProps({ showHexData: false })
const PageContainerContentChild = wrapper.find(PageContainerContent).children()
assert(PageContainerContentChild.childAt(0).is(SendFromRow))
assert(PageContainerContentChild.childAt(1).is(SendToRow))
assert(PageContainerContentChild.childAt(2).is(SendAssetRow))
assert(PageContainerContentChild.childAt(3).is(SendAmountRow))
assert(PageContainerContentChild.childAt(4).is(SendGasRow))
assert.equal(PageContainerContentChild.childAt(5).exists(), false)
assert(PageContainerContentChild.childAt(0).is(Dialog), 'row[0] should be Dialog')
assert(PageContainerContentChild.childAt(1).is(SendAssetRow), 'row[1] should be SendAssetRow')
assert(PageContainerContentChild.childAt(2).is(SendAmountRow), 'row[2] should be SendAmountRow')
assert(PageContainerContentChild.childAt(3).is(SendGasRow), 'row[3] should be SendGasRow')
assert.equal(PageContainerContentChild.childAt(4).exists(), false)
})
it('should not render the Dialog if addressBook contains "to" address', () => {
wrapper.setProps({
showHexData: false,
to: '0x80F061544cC398520615B5d3e7A3BedD70cd4510',
addressBook: [{ address: '0x80F061544cC398520615B5d3e7A3BedD70cd4510', name: 'dinodan' }],
})
const PageContainerContentChild = wrapper.find(PageContainerContent).children()
assert(PageContainerContentChild.childAt(0).is(SendAssetRow), 'row[1] should be SendAssetRow')
assert(PageContainerContentChild.childAt(1).is(SendAmountRow), 'row[2] should be SendAmountRow')
assert(PageContainerContentChild.childAt(2).is(SendGasRow), 'row[3] should be SendGasRow')
assert.equal(PageContainerContentChild.childAt(3).exists(), false)
})
it('should not render the Dialog if ownedAccounts contains "to" address', () => {
wrapper.setProps({
showHexData: false,
to: '0x80F061544cC398520615B5d3e7A3BedD70cd4510',
addressBook: [],
ownedAccounts: [{ address: '0x80F061544cC398520615B5d3e7A3BedD70cd4510', name: 'dinodan' }],
})
const PageContainerContentChild = wrapper.find(PageContainerContent).children()
assert(PageContainerContentChild.childAt(0).is(SendAssetRow), 'row[1] should be SendAssetRow')
assert(PageContainerContentChild.childAt(1).is(SendAmountRow), 'row[2] should be SendAmountRow')
assert(PageContainerContentChild.childAt(2).is(SendGasRow), 'row[3] should be SendGasRow')
assert.equal(PageContainerContentChild.childAt(3).exists(), false)
})
})
it('should not render the asset dropdown if token length is 0 ', () => {
wrapper.setProps({ tokens: [] })
const PageContainerContentChild = wrapper.find(PageContainerContent).children()
assert(PageContainerContentChild.childAt(2).is(SendAssetRow))
assert(PageContainerContentChild.childAt(2).find('send-v2__asset-dropdown__single-asset'), true)
assert(PageContainerContentChild.childAt(1).is(SendAssetRow))
assert(PageContainerContentChild.childAt(1).find('send-v2__asset-dropdown__single-asset'), true)
})
})

View File

@ -24,8 +24,10 @@ export default class SendHeader extends Component {
render () {
return (
<PageContainerHeader
className="send__header"
onClose={() => this.onClose()}
title={this.context.t(this.props.titleKey)}
headerCloseText={this.context.t('cancel')}
/>
)
}

View File

@ -1,6 +1,7 @@
const {
getSelectedToken,
getSendEditingTransactionId,
getSendTo,
} = require('../send.selectors.js')
const selectors = {
@ -14,6 +15,10 @@ function getTitleKey (state) {
const isEditing = Boolean(getSendEditingTransactionId(state))
const isToken = Boolean(getSelectedToken(state))
if (!getSendTo(state)) {
return 'addRecipient'
}
if (isEditing) {
return 'edit'
} else if (isToken) {

View File

@ -8,39 +8,44 @@ const {
'../send.selectors': {
getSelectedToken: (mockState) => mockState.t,
getSendEditingTransactionId: (mockState) => mockState.e,
getSendTo: (mockState) => mockState.to,
},
})
describe('send-header selectors', () => {
describe('getTitleKey()', () => {
it('should return the correct key when "to" is empty', () => {
assert.equal(getTitleKey({ e: 1, t: true, to: '' }), 'addRecipient')
})
it('should return the correct key when getSendEditingTransactionId is truthy', () => {
assert.equal(getTitleKey({ e: 1, t: true }), 'edit')
assert.equal(getTitleKey({ e: 1, t: true, to: '0x123' }), 'edit')
})
it('should return the correct key when getSendEditingTransactionId is falsy and getSelectedToken is truthy', () => {
assert.equal(getTitleKey({ e: null, t: 'abc' }), 'sendTokens')
assert.equal(getTitleKey({ e: null, t: 'abc', to: '0x123' }), 'sendTokens')
})
it('should return the correct key when getSendEditingTransactionId is falsy and getSelectedToken is falsy', () => {
assert.equal(getTitleKey({ e: null }), 'sendETH')
assert.equal(getTitleKey({ e: null, to: '0x123' }), 'sendETH')
})
})
describe('getSubtitleParams()', () => {
it('should return the correct params when getSendEditingTransactionId is truthy', () => {
assert.deepEqual(getSubtitleParams({ e: 1, t: true }), [ 'editingTransaction' ])
assert.deepEqual(getSubtitleParams({ e: 1, t: true, to: '0x123' }), [ 'editingTransaction' ])
})
it('should return the correct params when getSendEditingTransactionId is falsy and getSelectedToken is truthy', () => {
assert.deepEqual(
getSubtitleParams({ e: null, t: { symbol: 'ABC' } }),
getSubtitleParams({ e: null, t: { symbol: 'ABC' }, to: '0x123' }),
[ 'onlySendTokensToAccountAddress', [ 'ABC' ] ]
)
})
it('should return the correct params when getSendEditingTransactionId is falsy and getSelectedToken is falsy', () => {
assert.deepEqual(getSubtitleParams({ e: null }), [ 'onlySendToEtherAddress' ])
assert.deepEqual(getSubtitleParams({ e: null, to: '0x123' }), [ 'onlySendToEtherAddress' ])
})
})

View File

@ -7,10 +7,14 @@ import {
getToAddressForGasUpdate,
doesAmountErrorRequireUpdate,
} from './send.utils'
import debounce from 'lodash.debounce'
import { getToWarningObject, getToErrorObject } from './send-content/add-recipient/add-recipient'
import SendHeader from './send-header'
import AddRecipient from './send-content/add-recipient'
import SendContent from './send-content'
import SendFooter from './send-footer'
import EnsInput from './send-content/add-recipient/ens-input'
export default class SendTransactionScreen extends PersistentForm {
@ -27,12 +31,14 @@ export default class SendTransactionScreen extends PersistentForm {
gasLimit: PropTypes.string,
gasPrice: PropTypes.string,
gasTotal: PropTypes.string,
to: PropTypes.string,
history: PropTypes.object,
network: PropTypes.string,
primaryCurrency: PropTypes.string,
recentBlocks: PropTypes.array,
selectedAddress: PropTypes.string,
selectedToken: PropTypes.object,
tokens: PropTypes.array,
tokenBalance: PropTypes.string,
tokenContract: PropTypes.object,
fetchBasicGasEstimates: PropTypes.func,
@ -42,10 +48,24 @@ export default class SendTransactionScreen extends PersistentForm {
scanQrCode: PropTypes.func,
qrCodeDetected: PropTypes.func,
qrCodeData: PropTypes.object,
ensResolution: PropTypes.string,
ensResolutionError: PropTypes.string,
}
static contextTypes = {
t: PropTypes.func,
metricsEvent: PropTypes.func,
}
state = {
query: '',
toError: null,
toWarning: null,
}
constructor (props) {
super(props)
this.dValidate = debounce(this.validate, 1000)
}
componentWillReceiveProps (nextProps) {
@ -63,34 +83,6 @@ export default class SendTransactionScreen extends PersistentForm {
}
}
updateGas ({ to: updatedToAddress, amount: value, data } = {}) {
const {
amount,
blockGasLimit,
editingTransactionId,
gasLimit,
gasPrice,
recentBlocks,
selectedAddress,
selectedToken = {},
to: currentToAddress,
updateAndSetGasLimit,
} = this.props
updateAndSetGasLimit({
blockGasLimit,
editingTransactionId,
gasLimit,
gasPrice,
recentBlocks,
selectedAddress,
selectedToken,
to: getToAddressForGasUpdate(updatedToAddress, currentToAddress),
value: value || amount,
data,
})
}
componentDidUpdate (prevProps) {
const {
amount,
@ -105,6 +97,10 @@ export default class SendTransactionScreen extends PersistentForm {
updateSendErrors,
updateSendTokenBalance,
tokenContract,
to,
toNickname,
addressBook,
updateToNicknameIfNecessary,
} = this.props
const {
@ -159,6 +155,7 @@ export default class SendTransactionScreen extends PersistentForm {
tokenContract,
address,
})
updateToNicknameIfNecessary(to, toNickname, addressBook)
this.updateGas()
}
}
@ -173,9 +170,9 @@ export default class SendTransactionScreen extends PersistentForm {
componentDidMount () {
this.props.fetchBasicGasEstimates()
.then(() => {
this.updateGas()
})
.then(() => {
this.updateGas()
})
}
componentWillMount () {
@ -196,6 +193,39 @@ export default class SendTransactionScreen extends PersistentForm {
this.props.resetSendState()
}
onRecipientInputChange = query => {
if (query) {
this.dValidate(query)
} else {
this.validate(query)
}
this.setState({
query,
})
}
validate (query) {
const {
hasHexData,
tokens,
selectedToken,
network,
} = this.props
if (!query) {
return this.setState({ toError: '', toWarning: '' })
}
const toErrorObject = getToErrorObject(query, null, hasHexData, tokens, selectedToken, network)
const toWarningObject = getToWarningObject(query, null, tokens, selectedToken)
this.setState({
toError: toErrorObject.to,
toWarning: toWarningObject.to,
})
}
updateSendToken () {
const {
from: { address },
@ -211,20 +241,103 @@ export default class SendTransactionScreen extends PersistentForm {
})
}
updateGas ({ to: updatedToAddress, amount: value, data } = {}) {
const {
amount,
blockGasLimit,
editingTransactionId,
gasLimit,
gasPrice,
recentBlocks,
selectedAddress,
selectedToken = {},
to: currentToAddress,
updateAndSetGasLimit,
} = this.props
updateAndSetGasLimit({
blockGasLimit,
editingTransactionId,
gasLimit,
gasPrice,
recentBlocks,
selectedAddress,
selectedToken,
to: getToAddressForGasUpdate(updatedToAddress, currentToAddress),
value: value || amount,
data,
})
}
render () {
const { history, showHexData } = this.props
const { history, to } = this.props
let content
if (to) {
content = this.renderSendContent()
} else {
content = this.renderAddRecipient()
}
return (
<div className="page-container">
<SendHeader history={history}/>
<SendContent
updateGas={(updateData) => this.updateGas(updateData)}
scanQrCode={_ => this.props.scanQrCode()}
showHexData={showHexData}
/>
<SendFooter history={history}/>
<SendHeader history={history} />
{ this.renderInput() }
{ content }
</div>
)
}
renderInput () {
return (
<EnsInput
className="send__to-row"
scanQrCode={_ => {
this.context.metricsEvent({
eventOpts: {
category: 'Transactions',
action: 'Edit Screen',
name: 'Used QR scanner',
},
})
this.props.scanQrCode()
}}
onChange={this.onRecipientInputChange}
onPaste={text => this.props.updateSendTo(text)}
onReset={() => this.props.updateSendTo('', '')}
updateEnsResolution={this.props.updateSendEnsResolution}
updateEnsResolutionError={this.props.updateSendEnsResolutionError}
/>
)
}
renderAddRecipient () {
const { scanQrCode } = this.props
const { toError, toWarning } = this.state
return (
<AddRecipient
updateGas={({ to, amount, data } = {}) => this.updateGas({ to, amount, data })}
scanQrCode={scanQrCode}
query={this.state.query}
toError={toError}
toWarning={toWarning}
/>
)
}
renderSendContent () {
const { history, showHexData, scanQrCode } = this.props
return [
<SendContent
key="send-content"
updateGas={({ to, amount, data } = {}) => this.updateGas({ to, amount, data })}
scanQrCode={scanQrCode}
showHexData={showHexData}
/>,
<SendFooter key="send-footer" history={history} />,
]
}
}

View File

@ -24,9 +24,16 @@ import {
getSendHexDataFeatureFlagState,
getSendFromObject,
getSendTo,
getSendToNickname,
getTokenBalance,
getQrCodeData,
getSendEnsResolution,
getSendEnsResolutionError,
} from './send.selectors'
import {
getAddressBook,
} from '../../selectors/selectors'
import { getTokens } from './send-content/add-recipient/add-recipient.selectors'
import {
updateSendTo,
updateSendTokenBalance,
@ -34,6 +41,8 @@ import {
setGasTotal,
showQrScanner,
qrCodeDetected,
updateSendEnsResolution,
updateSendEnsResolutionError,
} from '../../store/actions'
import {
resetSendState,
@ -45,6 +54,9 @@ import {
import {
calcGasTotal,
} from './send.utils.js'
import {
isValidENSAddress,
} from '../../helpers/utils/util'
import {
SEND_ROUTE,
@ -72,11 +84,16 @@ function mapStateToProps (state) {
selectedAddress: getSelectedAddress(state),
selectedToken: getSelectedToken(state),
showHexData: getSendHexDataFeatureFlagState(state),
ensResolution: getSendEnsResolution(state),
ensResolutionError: getSendEnsResolutionError(state),
to: getSendTo(state),
toNickname: getSendToNickname(state),
tokens: getTokens(state),
tokenBalance: getTokenBalance(state),
tokenContract: getSelectedTokenContract(state),
tokenToFiatRate: getSelectedTokenToFiatRate(state),
qrCodeData: getQrCodeData(state),
addressBook: getAddressBook(state),
}
}
@ -111,5 +128,15 @@ function mapDispatchToProps (dispatch) {
qrCodeDetected: (data) => dispatch(qrCodeDetected(data)),
updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)),
fetchBasicGasEstimates: () => dispatch(fetchBasicGasEstimates()),
updateSendEnsResolution: (ensResolution) => dispatch(updateSendEnsResolution(ensResolution)),
updateSendEnsResolutionError: (message) => dispatch(updateSendEnsResolutionError(message)),
updateToNicknameIfNecessary: (to, toNickname, addressBook) => {
if (isValidENSAddress(toNickname)) {
const addressBookEntry = addressBook.find(({ address}) => to === address) || {}
if (!addressBookEntry.name !== toNickname) {
dispatch(updateSendTo(to, addressBookEntry.name || ''))
}
}
},
}
}

View File

@ -0,0 +1,233 @@
.send {
&__header {
position: relative;
background-color: $Grey-000;
border-bottom: none;
padding: 14px 0 3px 0;
.page-container__title {
@extend %h4;
text-align: center;
}
.page-container__header-close-text {
@extend %link;
font-size: 1rem;
line-height: 1.1875rem;
position: absolute;
right: 1rem;
}
}
&__dialog {
margin: 1rem;
cursor: pointer;
}
&__error-dialog {
margin: 1rem;
}
&__to-row {
margin: 0;
padding: .5rem;
flex: 0 0 auto;
background-color: $Grey-000;
border-bottom: 1px solid $alto;
}
&__select-recipient-wrapper {
@extend %col-nowrap;
flex: 1 1 auto;
height: 0;
&__list {
overflow-y: auto;
&__link {
@extend %link;
@extend %row-nowrap;
padding: 1rem;
font-size: 1rem;
border-bottom: 1px solid $alto;
align-items: center;
}
&__back-caret {
@extend %bg-contain;
display: block;
background-image: url('/images/caret-left.svg');
width: 18px;
height: 18px;
margin-right: .5rem;
}
}
&__recent-group-wrapper {
@extend %col-nowrap;
&__load-more {
@extend %link;
font-size: .75rem;
line-height: 1.0625rem;
padding: .5rem;
text-align: center;
border-bottom: 1px solid $alto;
}
}
&__group {
@extend %col-nowrap;
}
&__group-label {
@extend %h8;
background-color: $Grey-000;
color: $Grey-600;
line-height: .875rem;
padding: .5rem 1rem;
border-bottom: 1px solid $alto;
&:first-of-type {
border-top: 1px solid $alto;
}
}
&__group-item, &__group-item--selected {
@extend %row-nowrap;
padding: .75rem 1rem;
align-items: center;
border-bottom: 1px solid $alto;
cursor: pointer;
&:hover {
background-color: rgba($alto, 0.2);
}
.identicon {
margin-right: 1rem;
flex: 0 0 auto;
}
&__content {
@extend %col-nowrap;
flex: 1 1 auto;
width: 0;
}
&__title {
font-size: .875rem;
line-height: 1.25rem;
color: $black;
}
&__subtitle {
@extend %h8;
color: $Grey-500;
}
}
&__group-item--selected {
border: 2px solid #2b7cd6;
border-radius: 8px;
}
}
}
.ens-input {
@extend %row-nowrap;
&__wrapper {
@extend %row-nowrap;
flex: 1 1 auto;
width: 0;
align-items: center;
background: $white;
border-radius: .5rem;
padding: .75rem .5rem;
border: 1px solid $Grey-100;
transition: border-color 150ms ease-in-out;
&:focus-within {
border-color: $Grey-500;
}
&__status-icon {
@extend %bg-contain;
background-image: url("/images/search-black.svg");
width: 1.125rem;
height: 1.125rem;
margin: .25rem .5rem .25rem .25rem;
&--error {
}
&--valid {
background-image: url("/images/check-green-solid.svg");
}
}
&__input {
@extend %h6;
flex: 1 1 auto;
width: 0;
border: 0;
outline: none;
&::placeholder {
color: $Grey-200;
}
}
&__action-icon {
@extend %bg-contain;
cursor: pointer;
&--erase {
background-image: url("/images/close-gray.svg");
width: .75rem;
height: .75rem;
margin: 0 .25rem;
}
&--qrcode {
background-image: url("/images/qr-blue.svg");
width: 1.5rem;
height: 1.5rem;
margin: 0 .25rem;
}
}
&--valid {
border-color: $Blue-500;
.ens-input__wrapper {
&__status-icon {
background-image: url("/images/check-green-solid.svg");
}
&__input {
@extend %col-nowrap;
font-size: .75rem;
line-height: .75rem;
font-weight: 400;
color: $Blue-500;
}
}
}
}
&__selected-input {
&__title {
@extend %ellipsify;
font-size: .875rem;
}
&__subtitle {
font-size: 0.75rem;
color: $Grey-500;
margin-top: .25rem;
}
}
}

View File

@ -6,6 +6,7 @@ const {
const {
getMetaMaskAccounts,
getSelectedAddress,
getAddressBook,
} = require('../../selectors/selectors')
const {
estimateGasPriceFromRecentBlocks,
@ -17,7 +18,6 @@ import {
const selectors = {
accountsWithSendEtherInfoSelector,
getAddressBook,
getAmountConversionRate,
getBlockGasLimit,
getConversionRate,
@ -43,6 +43,8 @@ const selectors = {
getSendHexData,
getSendHexDataFeatureFlagState,
getSendEditingTransactionId,
getSendEnsResolution,
getSendEnsResolutionError,
getSendErrors,
getSendFrom,
getSendFromBalance,
@ -50,6 +52,7 @@ const selectors = {
getSendMaxModeState,
getSendTo,
getSendToAccounts,
getSendToNickname,
getSendWarnings,
getTokenBalance,
getTokenExchangeRate,
@ -63,7 +66,6 @@ module.exports = selectors
function accountsWithSendEtherInfoSelector (state) {
const accounts = getMetaMaskAccounts(state)
const { identities } = state.metamask
const accountsWithSendEtherInfo = Object.entries(accounts).map(([key, account]) => {
return Object.assign({}, account, identities[key])
})
@ -71,10 +73,6 @@ function accountsWithSendEtherInfoSelector (state) {
return accountsWithSendEtherInfo
}
function getAddressBook (state) {
return state.metamask.addressBook
}
function getAmountConversionRate (state) {
return getSelectedToken(state)
? getSelectedTokenToFiatRate(state)
@ -237,6 +235,10 @@ function getSendTo (state) {
return state.metamask.send.to
}
function getSendToNickname (state) {
return state.metamask.send.toNickname
}
function getSendToAccounts (state) {
const fromAccounts = accountsWithSendEtherInfoSelector(state)
const addressBookAccounts = getAddressBook(state)
@ -251,6 +253,14 @@ function getTokenBalance (state) {
return state.metamask.send.tokenBalance
}
function getSendEnsResolution (state) {
return state.metamask.send.ensResolution
}
function getSendEnsResolutionError (state) {
return state.metamask.send.ensResolutionError
}
function getTokenExchangeRate (state, tokenSymbol) {
const pair = `${tokenSymbol.toLowerCase()}_eth`
const tokenExchangeRates = state.metamask.tokenExchangeRates

View File

@ -35,6 +35,7 @@ module.exports = {
isBalanceSufficient,
isTokenBalanceSufficient,
removeLeadingZeroes,
ellipsify,
}
function calcGasTotal (gasLimit = '0', gasPrice = '0') {
@ -330,3 +331,7 @@ function getToAddressForGasUpdate (...addresses) {
function removeLeadingZeroes (str) {
return str.replace(/^0*(?=\d)/, '')
}
function ellipsify (text, first = 6, last = 4) {
return `${text.slice(0, first)}...${text.slice(-last)}`
}

View File

@ -5,8 +5,9 @@ import { shallow } from 'enzyme'
import sinon from 'sinon'
import timeout from '../../../../lib/test-timeout'
import AddRecipient from '../send-content/add-recipient/add-recipient.container'
import SendHeader from '../send-header/send-header.container'
import SendContent from '../send-content/send-content.component'
import SendContent from '../send-content/send-content.container'
import SendFooter from '../send-footer/send-footer.container'
const mockBasicGasEstimates = {
@ -20,6 +21,7 @@ const propsMethodSpies = {
resetSendState: sinon.spy(),
fetchBasicGasEstimates: sinon.stub().returns(Promise.resolve(mockBasicGasEstimates)),
fetchGasEstimates: sinon.spy(),
updateToNicknameIfNecessary: sinon.spy(),
}
const utilsMethodStubs = {
getAmountErrorObject: sinon.stub().returns({ amount: 'mockAmountError' }),
@ -63,6 +65,7 @@ describe('Send Component', function () {
updateSendErrors={propsMethodSpies.updateSendErrors}
updateSendTokenBalance={propsMethodSpies.updateSendTokenBalance}
resetSendState={propsMethodSpies.resetSendState}
updateToNicknameIfNecessary={propsMethodSpies.updateToNicknameIfNecessary}
/>)
})
@ -332,13 +335,18 @@ describe('Send Component', function () {
assert.equal(wrapper.find('.page-container').length, 1)
})
it('should render SendHeader, SendContent and SendFooter', () => {
it('should render SendHeader and AddRecipient', () => {
assert.equal(wrapper.find(SendHeader).length, 1)
assert.equal(wrapper.find(SendContent).length, 1)
assert.equal(wrapper.find(SendFooter).length, 1)
assert.equal(wrapper.find(AddRecipient).length, 1)
})
it('should pass the history prop to SendHeader and SendFooter', () => {
wrapper.setProps({
to: '0x80F061544cC398520615B5d3e7A3BedD70cd4510',
})
assert.equal(wrapper.find(SendHeader).length, 1)
assert.equal(wrapper.find(SendContent).length, 1)
assert.equal(wrapper.find(SendFooter).length, 1)
assert.deepEqual(
wrapper.find(SendFooter).props(),
{
@ -348,7 +356,93 @@ describe('Send Component', function () {
})
it('should pass showHexData to SendContent', () => {
wrapper.setProps({
to: '0x80F061544cC398520615B5d3e7A3BedD70cd4510',
})
assert.equal(wrapper.find(SendContent).props().showHexData, true)
})
})
describe('validate when input change', () => {
let clock
beforeEach(() => {
clock = sinon.useFakeTimers()
})
afterEach(() => {
clock.restore()
})
it('should validate when input changes', () => {
const instance = wrapper.instance()
instance.onRecipientInputChange('0x80F061544cC398520615B5d3e7A3BedD70cd4510')
assert.deepEqual(instance.state, {
query: '0x80F061544cC398520615B5d3e7A3BedD70cd4510',
toError: null,
toWarning: null,
})
})
it('should validate when input changes and has error', () => {
const instance = wrapper.instance()
instance.onRecipientInputChange('0x80F061544cC398520615B5d3e7a3BedD70cd4510')
clock.tick(1001)
assert.deepEqual(instance.state, {
query: '0x80F061544cC398520615B5d3e7a3BedD70cd4510',
toError: 'invalidAddressRecipient',
toWarning: null,
})
})
it('should validate when input changes and has error', () => {
wrapper.setProps({ network: 'bad' })
const instance = wrapper.instance()
instance.onRecipientInputChange('0x80F061544cC398520615B5d3e7a3BedD70cd4510')
clock.tick(1001)
assert.deepEqual(instance.state, {
query: '0x80F061544cC398520615B5d3e7a3BedD70cd4510',
toError: 'invalidAddressRecipientNotEthNetwork',
toWarning: null,
})
})
it('should synchronously validate when input changes to ""', () => {
wrapper.setProps({ network: 'bad' })
const instance = wrapper.instance()
instance.onRecipientInputChange('0x80F061544cC398520615B5d3e7a3BedD70cd4510')
clock.tick(1001)
assert.deepEqual(instance.state, {
query: '0x80F061544cC398520615B5d3e7a3BedD70cd4510',
toError: 'invalidAddressRecipientNotEthNetwork',
toWarning: null,
})
instance.onRecipientInputChange('')
assert.deepEqual(instance.state, {
query: '',
toError: '',
toWarning: '',
})
})
it('should warn when send to a known token contract address', () => {
wrapper.setProps({
selectedToken: '0x888',
})
const instance = wrapper.instance()
instance.onRecipientInputChange('0x13cb85823f78Cff38f0B0E90D3e975b8CB3AAd64')
clock.tick(1001)
assert.deepEqual(instance.state, {
query: '0x13cb85823f78Cff38f0B0E90D3e975b8CB3AAd64',
toError: null,
toWarning: 'knownAddressRecipient',
})
})
})
})

View File

@ -41,12 +41,19 @@ proxyquire('../send.container.js', {
getSendHexDataFeatureFlagState: (s) => `mockSendHexDataFeatureFlagState:${s}`,
getSendAmount: (s) => `mockAmount:${s}`,
getSendTo: (s) => `mockTo:${s}`,
getSendToNickname: (s) => `mockToNickname:${s}`,
getSendEditingTransactionId: (s) => `mockEditingTransactionId:${s}`,
getSendFromObject: (s) => `mockFrom:${s}`,
getTokenBalance: (s) => `mockTokenBalance:${s}`,
getQrCodeData: (s) => `mockQrCodeData:${s}`,
getSendEnsResolution: (s) => `mockSendEnsResolution:${s}`,
getSendEnsResolutionError: (s) => `mockSendEnsResolutionError:${s}`,
},
'./send-content/add-recipient/add-recipient.selectors': {
getTokens: s => `mockTokens:${s}`,
},
'../../selectors/selectors': {
getAddressBook: (s) => `mockAddressBook:${s}`,
getSelectedAddress: (s) => `mockSelectedAddress:${s}`,
},
'../../store/actions': actionSpies,
@ -83,6 +90,11 @@ describe('send container', () => {
tokenContract: 'mockTokenContract:mockState',
tokenToFiatRate: 'mockTokenToFiatRate:mockState',
qrCodeData: 'mockQrCodeData:mockState',
tokens: 'mockTokens:mockState',
ensResolution: 'mockSendEnsResolution:mockState',
ensResolutionError: 'mockSendEnsResolutionError:mockState',
toNickname: 'mockToNickname:mockState',
addressBook: 'mockAddressBook:mockState',
})
})

View File

@ -60,6 +60,7 @@ module.exports = {
{
'address': '0x06195827297c7a80a443b6894d3bdb8824b43896',
'name': 'Address Book Account 1',
'chainId': '3',
},
],
'tokens': [

View File

@ -4,7 +4,6 @@ import selectors from '../send.selectors.js'
const {
accountsWithSendEtherInfoSelector,
// autoAddToBetaUI,
getAddressBook,
getBlockGasLimit,
getAmountConversionRate,
getConversionRate,
@ -103,20 +102,6 @@ describe('send selectors', () => {
// })
// })
describe('getAddressBook()', () => {
it('should return the address book', () => {
assert.deepEqual(
getAddressBook(mockState),
[
{
address: '0x06195827297c7a80a443b6894d3bdb8824b43896',
name: 'Address Book Account 1',
},
],
)
})
})
describe('getAmountConversionRate()', () => {
it('should return the token conversion rate if a token is selected', () => {
assert.equal(
@ -511,6 +496,7 @@ describe('send selectors', () => {
{
address: '0x06195827297c7a80a443b6894d3bdb8824b43896',
name: 'Address Book Account 1',
chainId: '3',
},
]
)

View File

@ -37,11 +37,7 @@ ToAutoComplete.prototype.renderDropdown = function () {
} = this.props
const { accountsToRender } = this.state
return accountsToRender.length && h('div', {}, [
h('div.send-v2__from-dropdown__close-area', {
onClick: closeDropdown,
}),
return !!accountsToRender.length && h('div', {}, [
h('div.send-v2__from-dropdown__list', {}, [
@ -93,7 +89,6 @@ ToAutoComplete.prototype.componentDidUpdate = function (nextProps) {
ToAutoComplete.prototype.render = function () {
const {
to,
dropdownOpen,
onChange,
inError,
qrScanner,
@ -118,12 +113,8 @@ ToAutoComplete.prototype.render = function () {
style: { color: '#33333' },
onClick: () => this.props.scanQrCode(),
})),
!to && h(`i.fa.fa-caret-down.fa-lg.send-v2__to-autocomplete__down-caret`, {
style: { color: '#dedede' },
onClick: () => this.handleInputEvent(),
}),
dropdownOpen && this.renderDropdown(),
this.renderDropdown(),
])
}

View File

@ -0,0 +1,131 @@
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import Identicon from '../../../../components/ui/identicon'
import TextField from '../../../../components/ui/text-field'
import { CONTACT_LIST_ROUTE } from '../../../../helpers/constants/routes'
import { isValidAddress, isValidENSAddress } from '../../../../helpers/utils/util'
import EnsInput from '../../../../pages/send/send-content/add-recipient/ens-input'
import PageContainerFooter from '../../../../components/ui/page-container/page-container-footer'
import debounce from 'lodash.debounce'
export default class AddContact extends PureComponent {
static contextTypes = {
t: PropTypes.func,
}
static propTypes = {
addToAddressBook: PropTypes.func,
history: PropTypes.object,
scanQrCode: PropTypes.func,
qrCodeData: PropTypes.object,
qrCodeDetected: PropTypes.func,
}
state = {
nickname: '',
ethAddress: '',
ensAddress: '',
error: '',
ensError: '',
}
constructor (props) {
super(props)
this.dValidate = debounce(this.validate, 1000)
}
componentWillReceiveProps (nextProps) {
if (nextProps.qrCodeData) {
if (nextProps.qrCodeData.type === 'address') {
const scannedAddress = nextProps.qrCodeData.values.address.toLowerCase()
const currentAddress = this.state.ensAddress || this.state.ethAddress
if (currentAddress.toLowerCase() !== scannedAddress) {
this.setState({ ethAddress: scannedAddress, ensAddress: '' })
// Clean up QR code data after handling
this.props.qrCodeDetected(null)
}
}
}
}
validate = address => {
const valid = isValidAddress(address)
const validEnsAddress = isValidENSAddress(address)
if (valid || validEnsAddress || address === '') {
this.setState({ error: '', ethAddress: address })
} else {
this.setState({ error: 'Invalid Address' })
}
}
renderInput () {
return (
<EnsInput
className="send__to-row"
scanQrCode={_ => { this.props.scanQrCode() }}
onChange={this.dValidate}
onPaste={text => this.setState({ ethAddress: text })}
onReset={() => this.setState({ ethAddress: '', ensAddress: '' })}
updateEnsResolution={address => {
this.setState({ ensAddress: address, error: '', ensError: '' })
}}
updateEnsResolutionError={message => this.setState({ ensError: message })}
/>
)
}
render () {
const { t } = this.context
const { history, addToAddressBook } = this.props
const errorToRender = this.state.ensError || this.state.error
return (
<div className="settings-page__content-row address-book__add-contact">
{this.state.ensAddress && <div className="address-book__view-contact__group">
<Identicon address={this.state.ensAddress} diameter={60} />
<div className="address-book__view-contact__group__value">
{ this.state.ensAddress }
</div>
</div>}
<div className="address-book__add-contact__content">
<div className="address-book__view-contact__group">
<div className="address-book__view-contact__group__label">
{ t('userName') }
</div>
<TextField
type="text"
id="nickname"
value={this.state.newName}
onChange={e => this.setState({ newName: e.target.value })}
fullWidth
margin="dense"
/>
</div>
<div className="address-book__view-contact__group">
<div className="address-book__view-contact__group__label">
{ t('ethereumPublicAddress') }
</div>
{ this.renderInput() }
{ errorToRender && <div className="address-book__add-contact__error">{errorToRender}</div>}
</div>
</div>
<PageContainerFooter
cancelText={this.context.t('cancel')}
disabled={Boolean(this.state.error)}
onSubmit={() => {
addToAddressBook(this.state.ensAddress || this.state.ethAddress, this.state.newName)
history.push(CONTACT_LIST_ROUTE)
}}
onCancel={() => {
history.push(CONTACT_LIST_ROUTE)
}}
submitText={this.context.t('save')}
submitButtonType={'confirm'}
/>
</div>
)
}
}

View File

@ -0,0 +1,30 @@
import AddContact from './add-contact.component'
import { compose } from 'recompose'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import { addToAddressBook, showQrScanner, qrCodeDetected } from '../../../../store/actions'
import {
CONTACT_ADD_ROUTE,
} from '../../../../helpers/constants/routes'
import {
getQrCodeData,
} from '../../../../pages/send/send.selectors'
const mapStateToProps = state => {
return {
qrCodeData: getQrCodeData(state),
}
}
const mapDispatchToProps = dispatch => {
return {
addToAddressBook: (recipient, nickname) => dispatch(addToAddressBook(recipient, nickname)),
scanQrCode: () => dispatch(showQrScanner(CONTACT_ADD_ROUTE)),
qrCodeDetected: (data) => dispatch(qrCodeDetected(data)),
}
}
export default compose(
withRouter,
connect(mapStateToProps, mapDispatchToProps)
)(AddContact)

View File

@ -0,0 +1 @@
export { default } from './add-contact.container'

View File

@ -0,0 +1,132 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import ContactList from '../../../components/app/contact-list'
import EditContact from './edit-contact'
import AddContact from './add-contact'
import ViewContact from './view-contact'
import MyAccounts from './my-accounts'
import {
CONTACT_ADD_ROUTE,
CONTACT_VIEW_ROUTE,
CONTACT_MY_ACCOUNTS_ROUTE,
} from '../../../helpers/constants/routes'
export default class ContactListTab extends Component {
static contextTypes = {
t: PropTypes.func,
}
static propTypes = {
addressBook: PropTypes.array,
history: PropTypes.object,
selectedAddress: PropTypes.string,
viewingContact: PropTypes.bool,
editingContact: PropTypes.bool,
addingContact: PropTypes.bool,
showContactContent: PropTypes.bool,
hideAddressBook: PropTypes.bool,
showingMyAccounts: PropTypes.bool,
}
renderAddresses () {
const { addressBook, history, selectedAddress } = this.props
const contacts = addressBook.filter(({ name }) => !!name)
const nonContacts = addressBook.filter(({ name }) => !name)
return (
<div>
<ContactList
searchForContacts={() => contacts}
searchForRecents={() => nonContacts}
selectRecipient={(address) => {
history.push(`${CONTACT_VIEW_ROUTE}/${address}`)
}}
selectedAddress={selectedAddress}
/>
</div>
)
}
renderAddButton () {
const { history } = this.props
return <div
className="address-book-add-button__button"
onClick={() => {
history.push(CONTACT_ADD_ROUTE)
}}>
<img
className="account-menu__item-icon"
src="images/plus-btn-white.svg"
/>
</div>
}
renderMyAccountsButton () {
const { history } = this.props
const { t } = this.context
return (
<div
className="address-book__my-accounts-button"
onClick={() => {
history.push(CONTACT_MY_ACCOUNTS_ROUTE)
}}
>
<div className="address-book__my-accounts-button__header">{t('myWalletAccounts')}</div>
<div className="address-book__my-accounts-button__content">
<div className="address-book__my-accounts-button__text">
{ t('myWalletAccountsDescription') }
</div>
<div className="address-book__my-accounts-button__caret" />
</div>
</div>
)
}
renderContactContent () {
const { viewingContact, editingContact, addingContact, showContactContent } = this.props
if (!showContactContent) {
return null
}
let ContactContentComponent = null
if (viewingContact) {
ContactContentComponent = ViewContact
} else if (editingContact) {
ContactContentComponent = EditContact
} else if (addingContact) {
ContactContentComponent = AddContact
}
return (ContactContentComponent && <div className="address-book-contact-content">
<ContactContentComponent />
</div>)
}
renderAddressBookContent () {
const { hideAddressBook, showingMyAccounts } = this.props
if (!hideAddressBook && !showingMyAccounts) {
return (<div className="address-book">
{ this.renderMyAccountsButton() }
{ this.renderAddresses() }
</div>)
} else if (!hideAddressBook && showingMyAccounts) {
return (<MyAccounts />)
}
}
render () {
const { addingContact } = this.props
return (
<div className="address-book-wrapper">
{ this.renderAddressBookContent() }
{ this.renderContactContent() }
{!addingContact && <div className="address-book-add-button">
{ this.renderAddButton() }
</div>}
</div>
)
}
}

View File

@ -0,0 +1,54 @@
import ContactListTab from './contact-list-tab.component'
import { compose } from 'recompose'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import { getAddressBook } from '../../../selectors/selectors'
import { ENVIRONMENT_TYPE_POPUP } from '../../../../../app/scripts/lib/enums'
import { getEnvironmentType } from '../../../../../app/scripts/lib/util'
import {
CONTACT_ADD_ROUTE,
CONTACT_EDIT_ROUTE,
CONTACT_VIEW_ROUTE,
CONTACT_MY_ACCOUNTS_ROUTE,
CONTACT_MY_ACCOUNTS_VIEW_ROUTE,
CONTACT_MY_ACCOUNTS_EDIT_ROUTE,
} from '../../../helpers/constants/routes'
const mapStateToProps = (state, ownProps) => {
const { location } = ownProps
const { pathname } = location
const pathNameTail = pathname.match(/[^/]+$/)[0]
const pathNameTailIsAddress = pathNameTail.includes('0x')
const viewingContact = Boolean(pathname.match(CONTACT_VIEW_ROUTE) || pathname.match(CONTACT_MY_ACCOUNTS_VIEW_ROUTE))
const editingContact = Boolean(pathname.match(CONTACT_EDIT_ROUTE) || pathname.match(CONTACT_MY_ACCOUNTS_EDIT_ROUTE))
const addingContact = Boolean(pathname.match(CONTACT_ADD_ROUTE))
const showingMyAccounts = Boolean(
pathname.match(CONTACT_MY_ACCOUNTS_ROUTE) ||
pathname.match(CONTACT_MY_ACCOUNTS_VIEW_ROUTE) ||
pathname.match(CONTACT_MY_ACCOUNTS_EDIT_ROUTE)
)
const envIsPopup = getEnvironmentType() === ENVIRONMENT_TYPE_POPUP
const hideAddressBook = envIsPopup && (viewingContact || editingContact || addingContact)
return {
viewingContact,
editingContact,
addingContact,
showingMyAccounts,
addressBook: getAddressBook(state),
selectedAddress: pathNameTailIsAddress ? pathNameTail : '',
hideAddressBook,
envIsPopup,
showContactContent: !envIsPopup || hideAddressBook,
}
}
export default compose(
withRouter,
connect(mapStateToProps)
)(ContactListTab)

View File

@ -0,0 +1,135 @@
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import Identicon from '../../../../components/ui/identicon'
import Button from '../../../../components/ui/button/button.component'
import TextField from '../../../../components/ui/text-field'
import { isValidAddress } from '../../../../helpers/utils/util'
import PageContainerFooter from '../../../../components/ui/page-container/page-container-footer'
export default class EditContact extends PureComponent {
static contextTypes = {
t: PropTypes.func,
}
static propTypes = {
addToAddressBook: PropTypes.func,
removeFromAddressBook: PropTypes.func,
history: PropTypes.object,
name: PropTypes.string,
address: PropTypes.string,
memo: PropTypes.string,
viewRoute: PropTypes.string,
listRoute: PropTypes.string,
setAccountLabel: PropTypes.func,
}
state = {
newName: '',
newAddress: '',
newMemo: '',
error: '',
}
render () {
const { t } = this.context
const { history, name, addToAddressBook, removeFromAddressBook, address, memo, viewRoute, listRoute, setAccountLabel } = this.props
return (
<div className="settings-page__content-row address-book__edit-contact">
<div className="settings-page__header address-book__header--edit">
<Identicon address={address} diameter={60}/>
<Button
type="link"
className="settings-page__address-book-button"
onClick={() => {
removeFromAddressBook(address)
history.push(listRoute)
}}
>
{t('deleteAccount')}
</Button>
</div>
<div className="address-book__edit-contact__content">
<div className="address-book__view-contact__group">
<div className="address-book__view-contact__group__label">
{ t('userName') }
</div>
<TextField
type="text"
id="nickname"
placeholder={this.context.t('addAlias')}
value={this.state.newName || name}
onChange={e => this.setState({ newName: e.target.value })}
fullWidth
margin="dense"
/>
</div>
<div className="address-book__view-contact__group">
<div className="address-book__view-contact__group__label">
{ t('ethereumPublicAddress') }
</div>
<TextField
type="text"
id="address"
placeholder={address}
value={this.state.newAddress || address}
error={this.state.error}
onChange={e => this.setState({ newAddress: e.target.value })}
fullWidth
margin="dense"
/>
</div>
<div className="address-book__view-contact__group">
<div className="address-book__view-contact__group__label--capitalized">
{ t('memo') }
</div>
<TextField
type="text"
id="memo"
placeholder={memo}
value={this.state.newMemo || memo}
onChange={e => this.setState({ newMemo: e.target.value })}
fullWidth
margin="dense"
multiline={true}
rows={3}
classes={{
inputMultiline: 'address-book__view-contact__text-area',
inputRoot: 'address-book__view-contact__text-area-wrapper',
}}
/>
</div>
</div>
<PageContainerFooter
cancelText={this.context.t('cancel')}
onSubmit={() => {
if (this.state.newAddress !== '' && this.state.newAddress !== address) {
// if the user makes a valid change to the address field, remove the original address
if (isValidAddress(this.state.newAddress)) {
removeFromAddressBook(address)
addToAddressBook(this.state.newAddress, this.state.newName || name, this.state.newMemo || memo)
setAccountLabel(this.state.newAddress, this.state.newName || name)
history.push(listRoute)
} else {
this.setState({ error: 'invalid address' })
}
} else {
// update name
addToAddressBook(address, this.state.newName || name, this.state.newMemo || memo)
setAccountLabel(address, this.state.newName || name)
history.push(listRoute)
}
}}
onCancel={() => {
history.push(`${viewRoute}/${address}`)
}}
submitText={this.context.t('save')}
submitButtonType={'confirm'}
/>
</div>
)
}
}

View File

@ -0,0 +1,47 @@
import EditContact from './edit-contact.component'
import { compose } from 'recompose'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import { getAddressBookEntry } from '../../../../selectors/selectors'
import {
CONTACT_VIEW_ROUTE,
CONTACT_MY_ACCOUNTS_ROUTE,
CONTACT_MY_ACCOUNTS_VIEW_ROUTE,
CONTACT_MY_ACCOUNTS_EDIT_ROUTE,
CONTACT_LIST_ROUTE,
} from '../../../../helpers/constants/routes'
import { addToAddressBook, removeFromAddressBook, setAccountLabel } from '../../../../store/actions'
const mapStateToProps = (state, ownProps) => {
const { location } = ownProps
const { pathname } = location
const pathNameTail = pathname.match(/[^/]+$/)[0]
const pathNameTailIsAddress = pathNameTail.includes('0x')
const address = pathNameTailIsAddress ? pathNameTail.toLowerCase() : ownProps.match.params.id
const { memo, name } = getAddressBookEntry(state, address) || state.metamask.identities[address]
const showingMyAccounts = Boolean(pathname.match(CONTACT_MY_ACCOUNTS_EDIT_ROUTE))
return {
address,
name,
memo,
viewRoute: showingMyAccounts ? CONTACT_MY_ACCOUNTS_VIEW_ROUTE : CONTACT_VIEW_ROUTE,
listRoute: showingMyAccounts ? CONTACT_MY_ACCOUNTS_ROUTE : CONTACT_LIST_ROUTE,
showingMyAccounts,
}
}
const mapDispatchToProps = dispatch => {
return {
addToAddressBook: (recipient, nickname, memo) => dispatch(addToAddressBook(recipient, nickname, memo)),
removeFromAddressBook: (addressToRemove) => dispatch(removeFromAddressBook(addressToRemove)),
setAccountLabel: (address, label) => dispatch(setAccountLabel(address, label)),
}
}
export default compose(
withRouter,
connect(mapStateToProps, mapDispatchToProps)
)(EditContact)

View File

@ -0,0 +1 @@
export { default } from './edit-contact.container'

View File

@ -0,0 +1 @@
export { default } from './contact-list-tab.container'

View File

@ -0,0 +1,234 @@
.address-book-wrapper {
display: flex;
justify-content: space-between;
height: 100%;
}
.address-book {
flex: 0.4 1 40%;
max-width: 40%;
@media screen and (max-width: 576px) {
flex: 1;
max-width: 100%;
}
&__entry {
display: flex;
flex-flow: row nowrap;
padding: 16px 14px;
flex: 0 0 auto;
border-bottom: 1px solid #dedede;
&:hover {
border: 1px solid #037DD6;
cursor: pointer;
}
}
&__name {
padding: 3px;
}
&__header, &__header--edit {
&__name {
font-family: Roboto;
font-style: normal;
font-weight: normal;
font-size: 24px;
line-height: 34px;
margin-left: 24px;
}
}
&__header--edit {
display: flex;
justify-content: space-between;
.button {
justify-content: flex-end;
color: #D73A49;
font-size: 14px;
}
}
&__input {
@extend %input-2;
margin-top: .25rem;
&--address {
font-size: 0.875rem;
}
}
&__view-contact {
&__text-area-wrapper {
height: 96px !important;
}
&__text-area {
line-height: initial !important;
}
&__group {
display: flex;
flex-flow: column nowrap;
padding: 1.5rem 1.5rem 0 1.5rem;
&__label, &__label--capitalized {
font-size: .75rem;
color: $Grey-500;
margin-bottom: .25rem;
}
&__label--capitalized {
text-transform: capitalize;
}
&__value, &__static-address {
display: flex;
flex-flow: row nowrap;
font-size: 1.125rem;
color: $Grey-800;
word-break: break-word;
&--address {
font-size: 0.875rem;
}
&--copy-icon {
padding-left: 4px;
}
}
&__static-address {
font-size: 0.875rem;
&--copy-icon {
cursor: pointer;
&:hover {
color: black;
}
}
}
.unit-input__input {
max-width: 100%;
width: 100%;
}
}
}
&__edit-contact {
display: flex;
flex-flow: column nowrap;
padding-bottom: 0 !important;
height: 100%;
&__content {
flex: 1 1 auto;
> div {
padding-top: 0;
}
}
.page-container__footer {
border-top: none;
}
}
&__add-contact {
display: flex;
flex-flow: column nowrap;
padding-bottom: 0 !important;
height: 100%;
&__content {
flex: 1 1 auto;
height: 100%;
}
&__error {
font-size: 12px;
line-height: 12px;
left: 8px;
color: $red;
}
}
&__my-accounts-button {
display: flex;
flex-flow: column;
cursor: pointer;
padding: 15px;
&:hover {
background-color: rgba(222, 222, 222, 0.2);
}
&__header {
font-family: Roboto;
font-style: normal;
font-weight: normal;
font-size: 18px;
line-height: 25px;
color: #000000;
}
&__content {
display: flex;
justify-content: space-between;
}
&__text {
font-family: Roboto;
font-style: normal;
font-weight: normal;
font-size: 14px;
line-height: 20px;
color: #6A737D;
}
&__caret {
display: block;
background-image: url(/images/caret-right.svg);
width: 30px;
opacity: .5;
background-repeat: no-repeat;
}
}
}
.address-book-add-button {
&__button {
position: absolute;
top: 10px;
right: 16px;
height: 56px;
width: 56px;
border-radius: 18px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
border-width: 2px;
background: #037DD6;
margin-right: 5px;
cursor: pointer;
box-shadow: 0px 2px 16px rgba(0, 0, 0, 0.25);
}
}
.address-book--hidden {
display: none;
}
.address-book-contact-content {
flex: 0.4 1 40%;
@media screen and (max-width: 576px) {
flex: 1
}
}

View File

@ -0,0 +1 @@
export { default } from './my-accounts.container'

View File

@ -0,0 +1,39 @@
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import ContactList from '../../../../components/app/contact-list'
import { CONTACT_MY_ACCOUNTS_VIEW_ROUTE } from '../../../../helpers/constants/routes'
export default class ViewContact extends PureComponent {
static contextTypes = {
t: PropTypes.func,
}
static propTypes = {
myAccounts: PropTypes.array,
history: PropTypes.object,
}
renderMyAccounts () {
const { myAccounts, history } = this.props
return (
<div>
<ContactList
searchForMyAccounts={() => myAccounts}
selectRecipient={(address) => {
history.push(`${CONTACT_MY_ACCOUNTS_VIEW_ROUTE}/${address}`)
}}
/>
</div>
)
}
render () {
return (
<div className="address-book">
{ this.renderMyAccounts() }
</div>
)
}
}

View File

@ -0,0 +1,18 @@
import ViewContact from './my-accounts.component'
import { compose } from 'recompose'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import { accountsWithSendEtherInfoSelector } from '../../../../selectors/selectors'
const mapStateToProps = (state,) => {
const myAccounts = accountsWithSendEtherInfoSelector(state)
return {
myAccounts,
}
}
export default compose(
withRouter,
connect(mapStateToProps)
)(ViewContact)

View File

@ -0,0 +1 @@
export { default } from './view-contact.container'

View File

@ -0,0 +1,78 @@
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import Identicon from '../../../../components/ui/identicon'
import Button from '../../../../components/ui/button/button.component'
import copyToClipboard from 'copy-to-clipboard'
function quadSplit (address) {
return '0x ' + address.slice(2).match(/.{1,4}/g).join(' ')
}
export default class ViewContact extends PureComponent {
static contextTypes = {
t: PropTypes.func,
}
static propTypes = {
removeFromAddressBook: PropTypes.func,
name: PropTypes.string,
address: PropTypes.string,
history: PropTypes.object,
checkSummedAddress: PropTypes.string,
memo: PropTypes.string,
editRoute: PropTypes.string,
}
render () {
const { t } = this.context
const { history, name, address, checkSummedAddress, memo, editRoute } = this.props
return (
<div className="settings-page__content-row">
<div className="settings-page__content-item">
<div className="settings-page__header address-book__header">
<Identicon address={address} diameter={60} />
<div className="address-book__header__name">{ name }</div>
</div>
<div className="address-book__view-contact__group">
<Button
type="secondary"
onClick={() => {
history.push(`${editRoute}/${address}`)
}}
>
{t('edit')}
</Button>
</div>
<div className="address-book__view-contact__group">
<div className="address-book__view-contact__group__label">
{ t('ethereumPublicAddress') }
</div>
<div className="address-book__view-contact__group__value">
<div
className="address-book__view-contact__group__static-address"
>
{ quadSplit(checkSummedAddress) }
</div>
<img
className="address-book__view-contact__group__static-address--copy-icon"
onClick={() => copyToClipboard(checkSummedAddress)}
src="/images/copy-to-clipboard.svg"
/>
</div>
</div>
<div className="address-book__view-contact__group">
<div className="address-book__view-contact__group__label--capitalized">
{ t('memo') }
</div>
<div className="address-book__view-contact__group__static-address">
{ memo }
</div>
</div>
</div>
</div>
)
}
}

View File

@ -0,0 +1,43 @@
import ViewContact from './view-contact.component'
import { compose } from 'recompose'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import { getAddressBookEntry } from '../../../../selectors/selectors'
import { removeFromAddressBook } from '../../../../store/actions'
import { checksumAddress } from '../../../../helpers/utils/util'
import {
CONTACT_EDIT_ROUTE,
CONTACT_MY_ACCOUNTS_EDIT_ROUTE,
CONTACT_MY_ACCOUNTS_VIEW_ROUTE,
} from '../../../../helpers/constants/routes'
const mapStateToProps = (state, ownProps) => {
const { location } = ownProps
const { pathname } = location
const pathNameTail = pathname.match(/[^/]+$/)[0]
const pathNameTailIsAddress = pathNameTail.includes('0x')
const address = pathNameTailIsAddress ? pathNameTail.toLowerCase() : ownProps.match.params.id
const { memo, name } = getAddressBookEntry(state, address) || state.metamask.identities[address]
const showingMyAccounts = Boolean(pathname.match(CONTACT_MY_ACCOUNTS_VIEW_ROUTE))
return {
name,
address,
checkSummedAddress: checksumAddress(address),
memo,
editRoute: showingMyAccounts ? CONTACT_MY_ACCOUNTS_EDIT_ROUTE : CONTACT_EDIT_ROUTE,
}
}
const mapDispatchToProps = dispatch => {
return {
removeFromAddressBook: (addressToRemove) => dispatch(removeFromAddressBook(addressToRemove)),
}
}
export default compose(
withRouter,
connect(mapStateToProps, mapDispatchToProps)
)(ViewContact)

View File

@ -1 +1 @@
export { default } from './settings.component'
export { default } from './settings.container'

View File

@ -4,6 +4,8 @@
@import 'settings-tab/index';
@import 'contact-list-tab/index';
.settings-page {
position: relative;
background: $white;
@ -23,7 +25,7 @@
}
}
&__subheader {
&__subheader, &__subheader--link {
padding: 16px 4px;
font-size: 20px;
border-bottom: 1px solid $alto;
@ -38,6 +40,16 @@
}
}
&__subheader--link {
cursor: pointer;
margin-right: 4px;
}
&__subheader--link:hover {
cursor: pointer;
color: #037DD6;
}
&__sub-header {
height: 72px;
border-bottom: 1px solid #D8D8D8;
@ -116,6 +128,8 @@
&__modules {
overflow-y: auto;
flex: 1 1 auto;
display: flex;
flex-flow: column;
@media screen and (max-width: 575px) {
display: none;
@ -175,6 +189,37 @@
}
}
&__copyable-address {
display: flex;
}
&__copy-icon {
padding-left: 4px;
}
&__button-group {
display:flex;
margin-left: auto;
}
&__address-book-button {
//align-self: flex-end;
//padding: 5px;
//text-transform: uppercase;
//cursor: pointer;
//width: 25%;
//min-width: 80px;
//height: 33px;
font-size: 1rem;
line-height: 1.1875rem;
padding: 0;
}
&__address-book-button + &__address-book-button {
margin-left: 1.875rem;
}
&--selected {
.settings-page {
&__content {

View File

@ -1,8 +1,6 @@
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import { Switch, Route, matchPath, withRouter } from 'react-router-dom'
import { ENVIRONMENT_TYPE_POPUP } from '../../../../app/scripts/lib/enums'
import { getEnvironmentType } from '../../../../app/scripts/lib/util'
import TabBar from '../../components/app/tab-bar'
import c from 'classnames'
import SettingsTab from './settings-tab'
@ -10,6 +8,7 @@ import NetworksTab from './networks-tab'
import AdvancedTab from './advanced-tab'
import InfoTab from './info-tab'
import SecurityTab from './security-tab'
import ContactListTab from './contact-list-tab'
import {
DEFAULT_ROUTE,
ADVANCED_ROUTE,
@ -18,19 +17,28 @@ import {
ABOUT_US_ROUTE,
SETTINGS_ROUTE,
NETWORKS_ROUTE,
CONTACT_LIST_ROUTE,
CONTACT_ADD_ROUTE,
CONTACT_EDIT_ROUTE,
CONTACT_VIEW_ROUTE,
CONTACT_MY_ACCOUNTS_ROUTE,
CONTACT_MY_ACCOUNTS_VIEW_ROUTE,
CONTACT_MY_ACCOUNTS_EDIT_ROUTE,
} from '../../helpers/constants/routes'
const ROUTES_TO_I18N_KEYS = {
[GENERAL_ROUTE]: 'general',
[ADVANCED_ROUTE]: 'advanced',
[SECURITY_ROUTE]: 'securityAndPrivacy',
[ABOUT_US_ROUTE]: 'about',
}
class SettingsPage extends PureComponent {
static propTypes = {
location: PropTypes.object,
addressName: PropTypes.string,
backRoute: PropTypes.string,
currentPath: PropTypes.string,
history: PropTypes.object,
isAddressEntryPage: PropTypes.bool,
isPopupView: PropTypes.bool,
location: PropTypes.object,
pathnameI18nKey: PropTypes.string,
initialBreadCrumbRoute: PropTypes.string,
breadCrumbTextKey: PropTypes.string,
initialBreadCrumbKey: PropTypes.string,
t: PropTypes.func,
}
@ -38,35 +46,25 @@ class SettingsPage extends PureComponent {
t: PropTypes.func,
}
isCurrentPath (pathname) {
return this.props.location.pathname === pathname
}
render () {
const { t } = this.context
const { history, location } = this.props
const pathnameI18nKey = ROUTES_TO_I18N_KEYS[location.pathname]
const isPopupView = getEnvironmentType(location.href) === ENVIRONMENT_TYPE_POPUP
const { history, backRoute, currentPath } = this.props
return (
<div
className={c('main-container settings-page', {
'settings-page--selected': !this.isCurrentPath(SETTINGS_ROUTE),
'settings-page--selected': currentPath !== SETTINGS_ROUTE,
})}
>
<div className="settings-page__header">
{
!this.isCurrentPath(SETTINGS_ROUTE) && !this.isCurrentPath(NETWORKS_ROUTE) && (
currentPath !== SETTINGS_ROUTE && currentPath !== NETWORKS_ROUTE && (
<div
className="settings-page__back-button"
onClick={() => history.push(SETTINGS_ROUTE)}
onClick={() => history.push(backRoute)}
/>
)
}
<div className="settings-page__header__title">
{t(pathnameI18nKey && isPopupView ? pathnameI18nKey : 'settings')}
</div>
{ this.renderTitle() }
<div
className="settings-page__close-button"
onClick={() => history.push(DEFAULT_ROUTE)}
@ -85,19 +83,65 @@ class SettingsPage extends PureComponent {
)
}
renderTitle () {
const { t } = this.context
const { isPopupView, pathnameI18nKey, addressName } = this.props
let titleText
if (isPopupView && addressName) {
titleText = addressName
} else if (pathnameI18nKey && isPopupView) {
titleText = t(pathnameI18nKey)
} else {
titleText = t('settings')
}
return (
<div className="settings-page__header__title">
{titleText}
</div>
)
}
renderSubHeader () {
const { t } = this.context
const { location: { pathname } } = this.props
const {
currentPath,
isPopupView,
isAddressEntryPage,
pathnameI18nKey,
addressName,
initialBreadCrumbRoute,
breadCrumbTextKey,
history,
initialBreadCrumbKey,
} = this.props
return pathname !== NETWORKS_ROUTE && (
let subheaderText
if (isPopupView && isAddressEntryPage) {
subheaderText = t('settings')
} else if (initialBreadCrumbKey) {
subheaderText = t(initialBreadCrumbKey)
} else {
subheaderText = t(pathnameI18nKey || 'general')
}
return currentPath !== NETWORKS_ROUTE && (
<div className="settings-page__subheader">
{t(ROUTES_TO_I18N_KEYS[pathname] || 'general')}
<div
className={c({ 'settings-page__subheader--link': initialBreadCrumbRoute })}
onClick={() => initialBreadCrumbRoute && history.push(initialBreadCrumbRoute)}
>{subheaderText}</div>
{breadCrumbTextKey && <div><span>{'> '}</span>{t(breadCrumbTextKey)}</div>}
{isAddressEntryPage && <div><span>{' > '}</span>{addressName}</div>}
</div>
)
}
renderTabs () {
const { history, location } = this.props
const { history, currentPath } = this.props
const { t } = this.context
return (
@ -105,15 +149,16 @@ class SettingsPage extends PureComponent {
tabs={[
{ content: t('general'), description: t('generalSettingsDescription'), key: GENERAL_ROUTE },
{ content: t('advanced'), description: t('advancedSettingsDescription'), key: ADVANCED_ROUTE },
{ content: t('contactList'), description: t('contactListDescription'), key: CONTACT_LIST_ROUTE },
{ content: t('securityAndPrivacy'), description: t('securitySettingsDescription'), key: SECURITY_ROUTE },
{ content: t('networks'), description: t('networkSettingsDescription'), key: NETWORKS_ROUTE },
{ content: t('about'), description: t('aboutSettingsDescription'), key: ABOUT_US_ROUTE },
]}
isActive={key => {
if (key === GENERAL_ROUTE && this.isCurrentPath(SETTINGS_ROUTE)) {
if (key === GENERAL_ROUTE && currentPath === SETTINGS_ROUTE) {
return true
}
return matchPath(location.pathname, { path: key, exact: true })
return matchPath(currentPath, { path: key, exact: true })
}}
onSelect={key => history.push(key)}
/>
@ -148,6 +193,41 @@ class SettingsPage extends PureComponent {
path={SECURITY_ROUTE}
component={SecurityTab}
/>
<Route
exact
path={CONTACT_LIST_ROUTE}
component={ContactListTab}
/>
<Route
exact
path={CONTACT_ADD_ROUTE}
component={ContactListTab}
/>
<Route
exact
path={CONTACT_MY_ACCOUNTS_ROUTE}
component={ContactListTab}
/>
<Route
exact
path={`${CONTACT_EDIT_ROUTE}/:id`}
component={ContactListTab}
/>
<Route
exact
path={`${CONTACT_VIEW_ROUTE}/:id`}
component={ContactListTab}
/>
<Route
exact
path={`${CONTACT_MY_ACCOUNTS_VIEW_ROUTE}/:id`}
component={ContactListTab}
/>
<Route
exact
path={`${CONTACT_MY_ACCOUNTS_EDIT_ROUTE}/:id`}
component={ContactListTab}
/>
<Route
component={SettingsTab}
/>

View File

@ -0,0 +1,92 @@
import Settings from './settings.component'
import { compose } from 'recompose'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import { getAddressBookEntryName } from '../../selectors/selectors'
import { isValidAddress } from '../../helpers/utils/util'
import { ENVIRONMENT_TYPE_POPUP } from '../../../../app/scripts/lib/enums'
import { getEnvironmentType } from '../../../../app/scripts/lib/util'
import {
ADVANCED_ROUTE,
SECURITY_ROUTE,
GENERAL_ROUTE,
ABOUT_US_ROUTE,
SETTINGS_ROUTE,
CONTACT_LIST_ROUTE,
CONTACT_ADD_ROUTE,
CONTACT_EDIT_ROUTE,
CONTACT_VIEW_ROUTE,
CONTACT_MY_ACCOUNTS_ROUTE,
CONTACT_MY_ACCOUNTS_EDIT_ROUTE,
CONTACT_MY_ACCOUNTS_VIEW_ROUTE,
} from '../../helpers/constants/routes'
const ROUTES_TO_I18N_KEYS = {
[GENERAL_ROUTE]: 'general',
[ADVANCED_ROUTE]: 'advanced',
[SECURITY_ROUTE]: 'securityAndPrivacy',
[ABOUT_US_ROUTE]: 'about',
[CONTACT_LIST_ROUTE]: 'contactList',
[CONTACT_ADD_ROUTE]: 'newContact',
[CONTACT_EDIT_ROUTE]: 'editContact',
[CONTACT_VIEW_ROUTE]: 'viewContact',
[CONTACT_MY_ACCOUNTS_ROUTE]: 'myAccounts',
}
const mapStateToProps = (state, ownProps) => {
const { location } = ownProps
const { pathname } = location
const pathNameTail = pathname.match(/[^/]+$/)[0]
const isAddressEntryPage = pathNameTail.includes('0x')
const isMyAccountsPage = pathname.match('my-accounts')
const isAddContactPage = Boolean(pathname.match(CONTACT_ADD_ROUTE))
const isEditContactPage = Boolean(pathname.match(CONTACT_EDIT_ROUTE))
const isEditMyAccountsContactPage = Boolean(pathname.match(CONTACT_MY_ACCOUNTS_EDIT_ROUTE))
const isPopupView = getEnvironmentType(location.href) === ENVIRONMENT_TYPE_POPUP
const pathnameI18nKey = ROUTES_TO_I18N_KEYS[pathname]
let backRoute
if (isMyAccountsPage && isAddressEntryPage) {
backRoute = CONTACT_MY_ACCOUNTS_ROUTE
} else if (isEditContactPage) {
backRoute = `${CONTACT_VIEW_ROUTE}/${pathNameTail}`
} else if (isEditMyAccountsContactPage) {
backRoute = `${CONTACT_MY_ACCOUNTS_VIEW_ROUTE}/${pathNameTail}`
} else if (isAddressEntryPage || isMyAccountsPage || isAddContactPage) {
backRoute = CONTACT_LIST_ROUTE
} else {
backRoute = SETTINGS_ROUTE
}
let initialBreadCrumbRoute
let breadCrumbTextKey
let initialBreadCrumbKey
if (isMyAccountsPage) {
initialBreadCrumbRoute = CONTACT_LIST_ROUTE
breadCrumbTextKey = 'myWalletAccounts'
initialBreadCrumbKey = ROUTES_TO_I18N_KEYS[initialBreadCrumbRoute]
}
const addressName = getAddressBookEntryName(state, isValidAddress(pathNameTail) ? pathNameTail : '')
return {
isAddressEntryPage,
isMyAccountsPage,
backRoute,
currentPath: pathname,
isPopupView,
pathnameI18nKey,
addressName,
initialBreadCrumbRoute,
breadCrumbTextKey,
initialBreadCrumbKey,
}
}
export default compose(
withRouter,
connect(mapStateToProps)
)(Settings)

View File

@ -9,6 +9,9 @@ import {
const {
multiplyCurrencies,
} = require('../helpers/utils/conversion-util')
import {
addressSlicer,
} from '../helpers/utils/util'
const selectors = {
getSelectedAddress,
@ -52,6 +55,8 @@ const selectors = {
getMetaMetricState,
getRpcPrefsForCurrentProvider,
getKnownMethodData,
getAddressBookEntry,
getAddressBookEntryName,
}
module.exports = selectors
@ -203,7 +208,22 @@ function conversionRateSelector (state) {
}
function getAddressBook (state) {
return state.metamask.addressBook
const network = state.metamask.network
const addressBookEntries = Object.values(state.metamask.addressBook)
.filter(entry => entry.chainId && entry.chainId.toString() === network)
return addressBookEntries
}
function getAddressBookEntry (state, address) {
const addressBook = getAddressBook(state)
const entry = addressBook.find(contact => contact.address.toLowerCase() === address.toLowerCase())
return entry
}
function getAddressBookEntryName (state, address) {
const entry = getAddressBookEntry(state, address) || state.metamask.identities[address]
return entry && entry.name !== '' ? entry.name : addressSlicer(address)
}
function accountsWithSendEtherInfoSelector (state) {

View File

@ -0,0 +1,232 @@
module.exports = {
'metamask': {
'isInitialized': true,
'isUnlocked': true,
'featureFlags': {'sendHexData': true},
'rpcTarget': 'https://rawtestrpc.metamask.io/',
'identities': {
'0xfdea65c8e26263f6d9a1b5de9555d2931a33b825': {
'address': '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825',
'name': 'Send Account 1',
},
'0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb': {
'address': '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb',
'name': 'Send Account 2',
},
'0x2f8d4a878cfa04a6e60d46362f5644deab66572d': {
'address': '0x2f8d4a878cfa04a6e60d46362f5644deab66572d',
'name': 'Send Account 3',
},
'0xd85a4b6a394794842887b8284293d69163007bbb': {
'address': '0xd85a4b6a394794842887b8284293d69163007bbb',
'name': 'Send Account 4',
},
},
'cachedBalances': {},
'currentBlockGasLimit': '0x4c1878',
'currentCurrency': 'USD',
'conversionRate': 1200.88200327,
'conversionDate': 1489013762,
'nativeCurrency': 'ETH',
'frequentRpcList': [],
'network': '3',
'accounts': {
'0xfdea65c8e26263f6d9a1b5de9555d2931a33b825': {
'code': '0x',
'balance': '0x47c9d71831c76efe',
'nonce': '0x1b',
'address': '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825',
},
'0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb': {
'code': '0x',
'balance': '0x37452b1315889f80',
'nonce': '0xa',
'address': '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb',
},
'0x2f8d4a878cfa04a6e60d46362f5644deab66572d': {
'code': '0x',
'balance': '0x30c9d71831c76efe',
'nonce': '0x1c',
'address': '0x2f8d4a878cfa04a6e60d46362f5644deab66572d',
},
'0xd85a4b6a394794842887b8284293d69163007bbb': {
'code': '0x',
'balance': '0x0',
'nonce': '0x0',
'address': '0xd85a4b6a394794842887b8284293d69163007bbb',
},
},
'addressBook': [
{
'address': '0x06195827297c7a80a443b6894d3bdb8824b43896',
'name': 'Address Book Account 1',
'chainId': '3',
},
],
'tokens': [
{
'address': '0x1a195821297c7a80a433b6894d3bdb8824b43896',
'decimals': 18,
'symbol': 'ABC',
},
{
'address': '0x8d6b81208414189a58339873ab429b6c47ab92d3',
'decimals': 4,
'symbol': 'DEF',
},
{
'address': '0xa42084c8d1d9a2198631988579bb36b48433a72b',
'decimals': 18,
'symbol': 'GHI',
},
],
'tokenExchangeRates': {
'def_eth': {
rate: 2.0,
},
'ghi_eth': {
rate: 31.01,
},
},
'transactions': {},
'selectedAddressTxList': [
{
'id': 'mockTokenTx1',
'txParams': {
'to': '0x8d6b81208414189a58339873ab429b6c47ab92d3',
},
'time': 1700000000000,
},
{
'id': 'mockTokenTx2',
'txParams': {
'to': '0xafaketokenaddress',
},
'time': 1600000000000,
},
{
'id': 'mockTokenTx3',
'txParams': {
'to': '0x8d6b81208414189a58339873ab429b6c47ab92d3',
},
'time': 1500000000000,
},
{
'id': 'mockEthTx1',
'txParams': {
'to': '0xd85a4b6a394794842887b8284293d69163007bbb',
},
'time': 1400000000000,
},
],
'selectedTokenAddress': '0x8d6b81208414189a58339873ab429b6c47ab92d3',
'unapprovedMsgs': {
'0xabc': { id: 'unapprovedMessage1', 'time': 1650000000000 },
'0xdef': { id: 'unapprovedMessage2', 'time': 1550000000000 },
'0xghi': { id: 'unapprovedMessage3', 'time': 1450000000000 },
},
'unapprovedMsgCount': 0,
'unapprovedPersonalMsgs': {},
'unapprovedPersonalMsgCount': 0,
'keyringTypes': [
'Simple Key Pair',
'HD Key Tree',
],
'keyrings': [
{
'type': 'HD Key Tree',
'accounts': [
'fdea65c8e26263f6d9a1b5de9555d2931a33b825',
'c5b8dbac4c1d3f152cdeb400e2313f309c410acb',
'2f8d4a878cfa04a6e60d46362f5644deab66572d',
],
},
{
'type': 'Simple Key Pair',
'accounts': [
'0xd85a4b6a394794842887b8284293d69163007bbb',
],
},
],
'selectedAddress': '0xd85a4b6a394794842887b8284293d69163007bbb',
'provider': {
'type': 'testnet',
},
'shapeShiftTxList': [
{ id: 'shapeShiftTx1', 'time': 1675000000000 },
{ id: 'shapeShiftTx2', 'time': 1575000000000 },
{ id: 'shapeShiftTx3', 'time': 1475000000000 },
],
'lostAccounts': [],
'send': {
'gasLimit': '0xFFFF',
'gasPrice': '0xaa',
'gasTotal': '0xb451dc41b578',
'tokenBalance': 3434,
'from': {
'address': '0xabcdefg',
'balance': '0x5f4e3d2c1',
},
'to': '0x987fedabc',
'amount': '0x080',
'memo': '',
'errors': {
'someError': null,
},
'maxModeOn': false,
'editingTransactionId': 97531,
'forceGasMin': true,
},
'unapprovedTxs': {
'4768706228115573': {
'id': 4768706228115573,
'time': 1487363153561,
'status': 'unapproved',
'gasMultiplier': 1,
'metamaskNetworkId': '3',
'txParams': {
'from': '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb',
'to': '0x18a3462427bcc9133bb46e88bcbe39cd7ef0e761',
'value': '0xde0b6b3a7640000',
'metamaskId': 4768706228115573,
'metamaskNetworkId': '3',
'gas': '0x5209',
},
'gasLimitSpecified': false,
'estimatedGas': '0x5209',
'txFee': '17e0186e60800',
'txValue': 'de0b6b3a7640000',
'maxCost': 'de234b52e4a0800',
'gasPrice': '4a817c800',
},
},
'currentLocale': 'en',
recentBlocks: ['mockBlock1', 'mockBlock2', 'mockBlock3'],
},
'appState': {
'menuOpen': false,
'currentView': {
'name': 'accountDetail',
'detailView': null,
'context': '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc',
},
'accountDetail': {
'subview': 'transactions',
},
'modal': {
'modalState': {},
'previousModalState': {},
},
'transForward': true,
'isLoading': false,
'warning': null,
'scrollToBottom': false,
'forgottenPassword': null,
},
'identities': {},
'send': {
'fromDropdownOpen': false,
'toDropdownOpen': false,
'errors': { 'someError': null },
},
}

View File

@ -0,0 +1,25 @@
import assert from 'assert'
import selectors from '../selectors.js'
const {
getAddressBook,
} = selectors
import mockState from './selectors-test-data'
describe('selectors', () => {
describe('getAddressBook()', () => {
it('should return the address book', () => {
assert.deepEqual(
getAddressBook(mockState),
[
{
address: '0x06195827297c7a80a443b6894d3bdb8824b43896',
name: 'Address Book Account 1',
chainId: '3',
},
],
)
})
})
})

View File

@ -1,7 +1,7 @@
const abi = require('human-standard-token-abi')
const pify = require('pify')
const getBuyEthUrl = require('../../../app/scripts/lib/buy-eth-url')
const { getTokenAddressFromTokenObject } = require('../helpers/utils/util')
const { getTokenAddressFromTokenObject, checksumAddress } = require('../helpers/utils/util')
const {
calcTokenBalance,
estimateGas,
@ -135,6 +135,8 @@ var actions = {
showSendTokenPage,
ADD_TO_ADDRESS_BOOK: 'ADD_TO_ADDRESS_BOOK',
addToAddressBook: addToAddressBook,
REMOVE_FROM_ADDRESS_BOOK: 'REMOVE_FROM_ADDRESS_BOOK',
removeFromAddressBook: removeFromAddressBook,
REQUEST_ACCOUNT_EXPORT: 'REQUEST_ACCOUNT_EXPORT',
requestExportAccount: requestExportAccount,
EXPORT_ACCOUNT: 'EXPORT_ACCOUNT',
@ -194,6 +196,10 @@ var actions = {
CLOSE_FROM_DROPDOWN: 'CLOSE_FROM_DROPDOWN',
GAS_LOADING_STARTED: 'GAS_LOADING_STARTED',
GAS_LOADING_FINISHED: 'GAS_LOADING_FINISHED',
UPDATE_SEND_ENS_RESOLUTION: 'UPDATE_SEND_ENS_RESOLUTION',
UPDATE_SEND_ENS_RESOLUTION_ERROR: 'UPDATE_SEND_ENS_RESOLUTION_ERROR',
updateSendEnsResolution,
updateSendEnsResolutionError,
setGasLimit,
setGasPrice,
updateGasData,
@ -1079,6 +1085,20 @@ function clearSend () {
}
}
function updateSendEnsResolution (ensResolution) {
return {
type: actions.UPDATE_SEND_ENS_RESOLUTION,
payload: ensResolution,
}
}
function updateSendEnsResolutionError (errorMessage) {
return {
type: actions.UPDATE_SEND_ENS_RESOLUTION_ERROR,
payload: errorMessage,
}
}
function sendTx (txData) {
log.info(`actions - sendTx: ${JSON.stringify(txData.txParams)}`)
@ -1924,17 +1944,28 @@ function delRpcTarget (oldRpc) {
}
}
// Calls the addressBookController to add a new address.
function addToAddressBook (recipient, nickname = '') {
function addToAddressBook (recipient, nickname = '', memo = '') {
log.debug(`background.addToAddressBook`)
return (dispatch) => {
background.setAddressBook(recipient, nickname, (err) => {
if (err) {
log.error(err)
return dispatch(self.displayWarning('Address book failed to update'))
}
})
return (dispatch, getState) => {
const chainId = getState().metamask.network
const set = background.setAddressBook(checksumAddress(recipient), nickname, chainId, memo)
if (!set) {
return dispatch(displayWarning('Address book failed to update'))
}
}
}
/**
* @description Calls the addressBookController to remove an existing address.
* @param {String} addressToRemove - Address of the entry to remove from the address book
*/
function removeFromAddressBook (addressToRemove) {
log.debug(`background.removeFromAddressBook`)
return () => {
background.removeFromAddressBook(checksumAddress(addressToRemove))
}
}

View File

@ -1872,6 +1872,11 @@
"@types/unist" "*"
"@types/vfile-message" "*"
"@types/xtend@^4.0.2":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@types/xtend/-/xtend-4.0.2.tgz#07b60212f1f92b6635cb719c8b4a5521ef0d685c"
integrity sha1-B7YCEvH5K2Y1y3Gci0pVIe8NaFw=
"@webassemblyjs/ast@1.8.5":
version "1.8.5"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359"
@ -10152,11 +10157,12 @@ fuse.js@^3.4.4:
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.4.5.tgz#8954fb43f9729bd5dbcb8c08f251db552595a7a6"
integrity sha512-s9PGTaQIkT69HaeoTVjwGsLfb8V8ScJLx5XGFcKHg0MqLUH/UZ4EKOtqtXX9k7AFqCGxD1aJmYb8Q5VYDibVRQ==
gaba@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/gaba/-/gaba-1.4.1.tgz#aa4bc235eb4420e5344389a069eb87c255bc75cf"
integrity sha512-samplOuwkL9Cjb55G5vCNpb0aoeblFk2mC09+UfQJ7E0tc0abdeDv4OGEFZF3wgWTl7FR++Dki40yeMHgj+PdQ==
gaba@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/gaba/-/gaba-1.5.0.tgz#1637886f73f1fe5964e321437f4a40c7ce065527"
integrity sha512-3gMyA0uYPap7uFnuZLSczjFlhhnReAMTdo70ks+H0Liho6rXVGk9jlzP/pIJ9+lQbU90552FWHuKjNapD4Y5+w==
dependencies:
"@types/xtend" "^4.0.2"
await-semaphore "^0.1.3"
eth-contract-metadata "^1.9.1"
eth-json-rpc-infura "^3.1.2"