aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--app/_locales/en/messages.json81
-rw-r--r--app/images/check-green-solid.svg4
-rwxr-xr-xapp/images/close-gray.svg4
-rw-r--r--app/images/qr-blue.svg7
-rw-r--r--app/images/search-black.svg4
-rw-r--r--app/scripts/metamask-controller.js1
-rw-r--r--package.json2
-rw-r--r--test/data/mock-state.json3
-rw-r--r--test/e2e/from-import-ui.spec.js9
-rw-r--r--test/e2e/metamask-responsive-ui.spec.js9
-rw-r--r--test/e2e/metamask-ui.spec.js65
-rwxr-xr-xtest/e2e/run-all.sh8
-rw-r--r--test/e2e/send-edit.spec.js288
-rw-r--r--test/integration/lib/send-new-ui.js168
-rw-r--r--test/unit/ui/app/actions.spec.js2
-rw-r--r--test/unit/ui/app/reducers/metamask.spec.js22
-rw-r--r--ui/app/components/app/app-header/index.scss3
-rw-r--r--ui/app/components/app/contact-list/contact-list.component.js114
-rw-r--r--ui/app/components/app/contact-list/index.js1
-rw-r--r--ui/app/components/app/contact-list/recipient-group/index.js1
-rw-r--r--ui/app/components/app/contact-list/recipient-group/recipient-group.component.js59
-rw-r--r--ui/app/components/app/ens-input.js181
-rw-r--r--ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.component.js79
-rw-r--r--ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.container.js18
-rw-r--r--ui/app/components/app/modals/add-to-addressbook-modal/index.js1
-rw-r--r--ui/app/components/app/modals/add-to-addressbook-modal/index.scss37
-rw-r--r--ui/app/components/app/modals/index.scss2
-rw-r--r--ui/app/components/app/modals/modal.js31
-rw-r--r--ui/app/components/ui/dialog/dialog.scss26
-rw-r--r--ui/app/components/ui/dialog/index.js26
-rw-r--r--ui/app/components/ui/page-container/page-container-header/page-container-header.component.js16
-rw-r--r--ui/app/components/ui/text-field/text-field.component.js3
-rw-r--r--ui/app/css/itcss/components/index.scss1
-rw-r--r--ui/app/css/itcss/components/send.scss58
-rw-r--r--ui/app/css/itcss/settings/variables.scss100
-rw-r--r--ui/app/ducks/metamask/metamask.js20
-rw-r--r--ui/app/helpers/constants/routes.js14
-rw-r--r--ui/app/pages/index.scss2
-rw-r--r--ui/app/pages/send/send-content/add-recipient/add-recipient.component.js243
-rw-r--r--ui/app/pages/send/send-content/add-recipient/add-recipient.container.js44
-rw-r--r--ui/app/pages/send/send-content/add-recipient/add-recipient.js (renamed from ui/app/pages/send/send-content/send-to-row/send-to-row.utils.js)0
-rw-r--r--ui/app/pages/send/send-content/add-recipient/add-recipient.selectors.js (renamed from ui/app/pages/send/send-content/send-to-row/send-to-row.selectors.js)0
-rw-r--r--ui/app/pages/send/send-content/add-recipient/ens-input.component.js268
-rw-r--r--ui/app/pages/send/send-content/add-recipient/ens-input.container.js20
-rw-r--r--ui/app/pages/send/send-content/add-recipient/ens-input.js1
-rw-r--r--ui/app/pages/send/send-content/add-recipient/index.js1
-rw-r--r--ui/app/pages/send/send-content/add-recipient/tests/add-recipient-component.test.js202
-rw-r--r--ui/app/pages/send/send-content/add-recipient/tests/add-recipient-container.test.js72
-rw-r--r--ui/app/pages/send/send-content/add-recipient/tests/add-recipient-selectors.test.js (renamed from ui/app/pages/send/send-content/send-to-row/tests/send-to-row-selectors.test.js)4
-rw-r--r--ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js (renamed from ui/app/pages/send/send-content/send-to-row/tests/send-to-row-utils.test.js)4
-rw-r--r--ui/app/pages/send/send-content/index.js2
-rw-r--r--ui/app/pages/send/send-content/send-content.component.js49
-rw-r--r--ui/app/pages/send/send-content/send-content.container.js38
-rw-r--r--ui/app/pages/send/send-content/send-to-row/index.js1
-rw-r--r--ui/app/pages/send/send-content/send-to-row/send-to-row-README.md0
-rw-r--r--ui/app/pages/send/send-content/send-to-row/send-to-row.component.js91
-rw-r--r--ui/app/pages/send/send-content/send-to-row/send-to-row.container.js54
-rw-r--r--ui/app/pages/send/send-content/send-to-row/tests/send-to-row-component.test.js166
-rw-r--r--ui/app/pages/send/send-content/send-to-row/tests/send-to-row-container.test.js134
-rw-r--r--ui/app/pages/send/send-content/tests/send-content-component.test.js63
-rw-r--r--ui/app/pages/send/send-header/send-header.component.js2
-rw-r--r--ui/app/pages/send/send-header/send-header.selectors.js5
-rw-r--r--ui/app/pages/send/send-header/tests/send-header-selectors.test.js17
-rw-r--r--ui/app/pages/send/send.component.js193
-rw-r--r--ui/app/pages/send/send.container.js27
-rw-r--r--ui/app/pages/send/send.scss233
-rw-r--r--ui/app/pages/send/send.selectors.js22
-rw-r--r--ui/app/pages/send/send.utils.js5
-rw-r--r--ui/app/pages/send/tests/send-component.test.js102
-rw-r--r--ui/app/pages/send/tests/send-container.test.js12
-rw-r--r--ui/app/pages/send/tests/send-selectors-test-data.js1
-rw-r--r--ui/app/pages/send/tests/send-selectors.test.js16
-rw-r--r--ui/app/pages/send/to-autocomplete/to-autocomplete.js13
-rw-r--r--ui/app/pages/settings/contact-list-tab/add-contact/add-contact.component.js131
-rw-r--r--ui/app/pages/settings/contact-list-tab/add-contact/add-contact.container.js30
-rw-r--r--ui/app/pages/settings/contact-list-tab/add-contact/index.js1
-rw-r--r--ui/app/pages/settings/contact-list-tab/contact-list-tab.component.js132
-rw-r--r--ui/app/pages/settings/contact-list-tab/contact-list-tab.container.js54
-rw-r--r--ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js135
-rw-r--r--ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js47
-rw-r--r--ui/app/pages/settings/contact-list-tab/edit-contact/index.js1
-rw-r--r--ui/app/pages/settings/contact-list-tab/index.js1
-rw-r--r--ui/app/pages/settings/contact-list-tab/index.scss234
-rw-r--r--ui/app/pages/settings/contact-list-tab/my-accounts/index.js1
-rw-r--r--ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.component.js39
-rw-r--r--ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.container.js18
-rw-r--r--ui/app/pages/settings/contact-list-tab/view-contact/index.js1
-rw-r--r--ui/app/pages/settings/contact-list-tab/view-contact/view-contact.component.js78
-rw-r--r--ui/app/pages/settings/contact-list-tab/view-contact/view-contact.container.js43
-rw-r--r--ui/app/pages/settings/index.js2
-rw-r--r--ui/app/pages/settings/index.scss47
-rw-r--r--ui/app/pages/settings/settings.component.js142
-rw-r--r--ui/app/pages/settings/settings.container.js92
-rw-r--r--ui/app/selectors/selectors.js22
-rw-r--r--ui/app/selectors/tests/selectors-test-data.js232
-rw-r--r--ui/app/selectors/tests/selectors.test.js25
-rw-r--r--ui/app/store/actions.js51
-rw-r--r--yarn.lock14
98 files changed, 4147 insertions, 1001 deletions
diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index e69b3dc36..1f60bfa57 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -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"
},
diff --git a/app/images/check-green-solid.svg b/app/images/check-green-solid.svg
new file mode 100644
index 000000000..3e58e8dcc
--- /dev/null
+++ b/app/images/check-green-solid.svg
@@ -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>
diff --git a/app/images/close-gray.svg b/app/images/close-gray.svg
new file mode 100755
index 000000000..fca1c4740
--- /dev/null
+++ b/app/images/close-gray.svg
@@ -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>
diff --git a/app/images/qr-blue.svg b/app/images/qr-blue.svg
new file mode 100644
index 000000000..54434295a
--- /dev/null
+++ b/app/images/qr-blue.svg
@@ -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>
diff --git a/app/images/search-black.svg b/app/images/search-black.svg
new file mode 100644
index 000000000..7b7db5124
--- /dev/null
+++ b/app/images/search-black.svg
@@ -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>
diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js
index 84a25b008..8ab2bc5dc 100644
--- a/app/scripts/metamask-controller.js
+++ b/app/scripts/metamask-controller.js
@@ -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),
diff --git a/package.json b/package.json
index 4240c1cb1..b728b826f 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/test/data/mock-state.json b/test/data/mock-state.json
index 671697182..122945ec1 100644
--- a/test/data/mock-state.json
+++ b/test/data/mock-state.json
@@ -119,7 +119,8 @@
"addressBook": [
{
"address": "0xc42edfcc21ed14dda456aa0756c153f7985d8813",
- "name": ""
+ "name": "",
+ "chainId": 4
}
],
"selectedTokenAddress": "0x108cf70c7d384c552f42c07c41c0e1e46d77ea0d",
diff --git a/test/e2e/from-import-ui.spec.js b/test/e2e/from-import-ui.spec.js
index 31a858cf1..82e811c86 100644
--- a/test/e2e/from-import-ui.spec.js
+++ b/test/e2e/from-import-ui.spec.js
@@ -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
diff --git a/test/e2e/metamask-responsive-ui.spec.js b/test/e2e/metamask-responsive-ui.spec.js
index 007b5cbf6..720cac333 100644
--- a/test/e2e/metamask-responsive-ui.spec.js
+++ b/test/e2e/metamask-responsive-ui.spec.js
@@ -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')
diff --git a/test/e2e/metamask-ui.spec.js b/test/e2e/metamask-ui.spec.js
index f9f52670b..006d8af60 100644
--- a/test/e2e/metamask-ui.spec.js
+++ b/test/e2e/metamask-ui.spec.js
@@ -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
diff --git a/test/e2e/run-all.sh b/test/e2e/run-all.sh
index 0ffa06735..b527f0025 100755
--- a/test/e2e/run-all.sh
+++ b/test/e2e/run-all.sh
@@ -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'
diff --git a/test/e2e/send-edit.spec.js b/test/e2e/send-edit.spec.js
new file mode 100644
index 000000000..b04e52f7f
--- /dev/null
+++ b/test/e2e/send-edit.spec.js
@@ -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()))
+ })
+ })
+})
diff --git a/test/integration/lib/send-new-ui.js b/test/integration/lib/send-new-ui.js
deleted file mode 100644
index 7c3bf7c21..000000000
--- a/test/integration/lib/send-new-ui.js
+++ /dev/null
@@ -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')
-}
diff --git a/test/unit/ui/app/actions.spec.js b/test/unit/ui/app/actions.spec.js
index 392321481..919bd81a6 100644
--- a/test/unit/ui/app/actions.spec.js
+++ b/test/unit/ui/app/actions.spec.js
@@ -869,7 +869,7 @@ describe('Actions', () => {
})
it('', () => {
- const store = mockStore()
+ const store = mockStore({ metamask: devState })
store.dispatch(actions.addToAddressBook('test'))
assert(addToAddressBookSpy.calledOnce)
})
diff --git a/test/unit/ui/app/reducers/metamask.spec.js b/test/unit/ui/app/reducers/metamask.spec.js
index 39caf3e6a..714bd476a 100644
--- a/test/unit/ui/app/reducers/metamask.spec.js
+++ b/test/unit/ui/app/reducers/metamask.spec.js
@@ -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)
+ })
})
diff --git a/ui/app/components/app/app-header/index.scss b/ui/app/components/app/app-header/index.scss
index d3f37b7a2..0ea1793ca 100644
--- a/ui/app/components/app/app-header/index.scss
+++ b/ui/app/components/app/app-header/index.scss
@@ -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;
}
}
diff --git a/ui/app/components/app/contact-list/contact-list.component.js b/ui/app/components/app/contact-list/contact-list.component.js
new file mode 100644
index 000000000..ec9b5f8eb
--- /dev/null
+++ b/ui/app/components/app/contact-list/contact-list.component.js
@@ -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>
+ )
+ }
+}
diff --git a/ui/app/components/app/contact-list/index.js b/ui/app/components/app/contact-list/index.js
new file mode 100644
index 000000000..d90c29b2b
--- /dev/null
+++ b/ui/app/components/app/contact-list/index.js
@@ -0,0 +1 @@
+export { default } from './contact-list.component'
diff --git a/ui/app/components/app/contact-list/recipient-group/index.js b/ui/app/components/app/contact-list/recipient-group/index.js
new file mode 100644
index 000000000..7d827523f
--- /dev/null
+++ b/ui/app/components/app/contact-list/recipient-group/index.js
@@ -0,0 +1 @@
+export { default } from './recipient-group.component'
diff --git a/ui/app/components/app/contact-list/recipient-group/recipient-group.component.js b/ui/app/components/app/contact-list/recipient-group/recipient-group.component.js
new file mode 100644
index 000000000..a2248326e
--- /dev/null
+++ b/ui/app/components/app/contact-list/recipient-group/recipient-group.component.js
@@ -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,
+}
diff --git a/ui/app/components/app/ens-input.js b/ui/app/components/app/ens-input.js
deleted file mode 100644
index 5eea0dd90..000000000
--- a/ui/app/components/app/ens-input.js
+++ /dev/null
@@ -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])
-}
diff --git a/ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.component.js b/ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.component.js
new file mode 100644
index 000000000..1ce9e8a06
--- /dev/null
+++ b/ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.component.js
@@ -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>
+ )
+ }
+}
diff --git a/ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.container.js b/ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.container.js
new file mode 100644
index 000000000..413d4aa4a
--- /dev/null
+++ b/ui/app/components/app/modals/add-to-addressbook-modal/add-to-addressbook-modal.container.js
@@ -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)
diff --git a/ui/app/components/app/modals/add-to-addressbook-modal/index.js b/ui/app/components/app/modals/add-to-addressbook-modal/index.js
new file mode 100644
index 000000000..9ed4f018f
--- /dev/null
+++ b/ui/app/components/app/modals/add-to-addressbook-modal/index.js
@@ -0,0 +1 @@
+export { default } from './add-to-addressbook-modal.container'
diff --git a/ui/app/components/app/modals/add-to-addressbook-modal/index.scss b/ui/app/components/app/modals/add-to-addressbook-modal/index.scss
new file mode 100644
index 000000000..f6bf85a0a
--- /dev/null
+++ b/ui/app/components/app/modals/add-to-addressbook-modal/index.scss
@@ -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;
+ }
+ }
+}
diff --git a/ui/app/components/app/modals/index.scss b/ui/app/components/app/modals/index.scss
index 09b0bb73c..1bbfd2d07 100644
--- a/ui/app/components/app/modals/index.scss
+++ b/ui/app/components/app/modals/index.scss
@@ -9,3 +9,5 @@
@import 'transaction-confirmed/index';
@import 'metametrics-opt-in-modal/index';
+
+@import './add-to-addressbook-modal/index';
diff --git a/ui/app/components/app/modals/modal.js b/ui/app/components/app/modals/modal.js
index cd8ec0c7d..4044ded8c 100644
--- a/ui/app/components/app/modals/modal.js
+++ b/ui/app/components/app/modals/modal.js
@@ -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 || {}
diff --git a/ui/app/components/ui/dialog/dialog.scss b/ui/app/components/ui/dialog/dialog.scss
new file mode 100644
index 000000000..68b5ce329
--- /dev/null
+++ b/ui/app/components/ui/dialog/dialog.scss
@@ -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;
+ }
+}
diff --git a/ui/app/components/ui/dialog/index.js b/ui/app/components/ui/dialog/index.js
new file mode 100644
index 000000000..d7e522b22
--- /dev/null
+++ b/ui/app/components/ui/dialog/index.js
@@ -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,
+}
diff --git a/ui/app/components/ui/page-container/page-container-header/page-container-header.component.js b/ui/app/components/ui/page-container/page-container-header/page-container-header.component.js
index 08f9c7544..f1e15f10f 100644
--- a/ui/app/components/ui/page-container/page-container-header/page-container-header.component.js
+++ b/ui/app/components/ui/page-container/page-container-header/page-container-header.component.js
@@ -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() }
diff --git a/ui/app/components/ui/text-field/text-field.component.js b/ui/app/components/ui/text-field/text-field.component.js
index 1153a595b..ac7712c65 100644
--- a/ui/app/components/ui/text-field/text-field.component.js
+++ b/ui/app/components/ui/text-field/text-field.component.js
@@ -61,6 +61,9 @@ const styles = {
...inputLabelBase,
fontSize: '.75rem',
},
+ inputMultiline: {
+ lineHeight: 'initial !important',
+ },
}
const TextField = props => {
diff --git a/ui/app/css/itcss/components/index.scss b/ui/app/css/itcss/components/index.scss
index dde66fbb3..0e2034670 100644
--- a/ui/app/css/itcss/components/index.scss
+++ b/ui/app/css/itcss/components/index.scss
@@ -1,4 +1,5 @@
@import '../../../components/ui/button/buttons';
+@import '../../../components/ui/dialog/dialog';
@import './footer.scss';
diff --git a/ui/app/css/itcss/components/send.scss b/ui/app/css/itcss/components/send.scss
index ee07c1a7e..81678408a 100644
--- a/ui/app/css/itcss/components/send.scss
+++ b/ui/app/css/itcss/components/send.scss
@@ -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;
diff --git a/ui/app/css/itcss/settings/variables.scss b/ui/app/css/itcss/settings/variables.scss
index c02be0d98..9257456ec 100644
--- a/ui/app/css/itcss/settings/variables.scss
+++ b/ui/app/css/itcss/settings/variables.scss
@@ -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;
+ }
+}
diff --git a/ui/app/ducks/metamask/metamask.js b/ui/app/ducks/metamask/metamask.js
index 92f190cf1..64f983606 100644
--- a/ui/app/ducks/metamask/metamask.js
+++ b/ui/app/ducks/metamask/metamask.js
@@ -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: {
diff --git a/ui/app/helpers/constants/routes.js b/ui/app/helpers/constants/routes.js
index 06b37274f..adcd3f14d 100644
--- a/ui/app/helpers/constants/routes.js
+++ b/ui/app/helpers/constants/routes.js
@@ -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,
}
diff --git a/ui/app/pages/index.scss b/ui/app/pages/index.scss
index cb9f0d80c..e7242392b 100644
--- a/ui/app/pages/index.scss
+++ b/ui/app/pages/index.scss
@@ -2,6 +2,8 @@
@import 'add-token/index';
+@import 'send/send';
+
@import 'confirm-add-token/index';
@import 'settings/index';
diff --git a/ui/app/pages/send/send-content/add-recipient/add-recipient.component.js b/ui/app/pages/send/send-content/add-recipient/add-recipient.component.js
new file mode 100644
index 000000000..e5edbc08d
--- /dev/null
+++ b/ui/app/pages/send/send-content/add-recipient/add-recipient.component.js
@@ -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>
+ )
+ }
+ }
+
+}
diff --git a/ui/app/pages/send/send-content/add-recipient/add-recipient.container.js b/ui/app/pages/send/send-content/add-recipient/add-recipient.container.js
new file mode 100644
index 000000000..eb980aa82
--- /dev/null
+++ b/ui/app/pages/send/send-content/add-recipient/add-recipient.container.js
@@ -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)),
+ }
+}
diff --git a/ui/app/pages/send/send-content/send-to-row/send-to-row.utils.js b/ui/app/pages/send/send-content/add-recipient/add-recipient.js
index b3b0d2da3..b3b0d2da3 100644
--- a/ui/app/pages/send/send-content/send-to-row/send-to-row.utils.js
+++ b/ui/app/pages/send/send-content/add-recipient/add-recipient.js
diff --git a/ui/app/pages/send/send-content/send-to-row/send-to-row.selectors.js b/ui/app/pages/send/send-content/add-recipient/add-recipient.selectors.js
index a6160d335..a6160d335 100644
--- a/ui/app/pages/send/send-content/send-to-row/send-to-row.selectors.js
+++ b/ui/app/pages/send/send-content/add-recipient/add-recipient.selectors.js
diff --git a/ui/app/pages/send/send-content/add-recipient/ens-input.component.js b/ui/app/pages/send/send-content/add-recipient/ens-input.component.js
new file mode 100644
index 000000000..c8d022079
--- /dev/null
+++ b/ui/app/pages/send/send-content/add-recipient/ens-input.component.js
@@ -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])
+}
diff --git a/ui/app/pages/send/send-content/add-recipient/ens-input.container.js b/ui/app/pages/send/send-content/add-recipient/ens-input.container.js
new file mode 100644
index 000000000..d74f44832
--- /dev/null
+++ b/ui/app/pages/send/send-content/add-recipient/ens-input.container.js
@@ -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)
diff --git a/ui/app/pages/send/send-content/add-recipient/ens-input.js b/ui/app/pages/send/send-content/add-recipient/ens-input.js
new file mode 100644
index 000000000..6833ccd03
--- /dev/null
+++ b/ui/app/pages/send/send-content/add-recipient/ens-input.js
@@ -0,0 +1 @@
+export { default } from './ens-input.container'
diff --git a/ui/app/pages/send/send-content/add-recipient/index.js b/ui/app/pages/send/send-content/add-recipient/index.js
new file mode 100644
index 000000000..d661bd74b
--- /dev/null
+++ b/ui/app/pages/send/send-content/add-recipient/index.js
@@ -0,0 +1 @@
+export { default } from './add-recipient.container'
diff --git a/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-component.test.js b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-component.test.js
new file mode 100644
index 000000000..7570e7fcb
--- /dev/null
+++ b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-component.test.js
@@ -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)
+ })
+ })
+})
diff --git a/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-container.test.js b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-container.test.js
new file mode 100644
index 000000000..5ca0b2c23
--- /dev/null
+++ b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-container.test.js
@@ -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']
+ )
+ })
+ })
+ })
+
+})
diff --git a/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-selectors.test.js b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-selectors.test.js
index 0fa342d1e..82f481187 100644
--- a/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-selectors.test.js
+++ b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-selectors.test.js
@@ -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', () => {
diff --git a/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-utils.test.js b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js
index f8a6dd96f..182504c5d 100644
--- a/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-utils.test.js
+++ b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js
@@ -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', () => {
diff --git a/ui/app/pages/send/send-content/index.js b/ui/app/pages/send/send-content/index.js
index 891c17e6a..542da4674 100644
--- a/ui/app/pages/send/send-content/index.js
+++ b/ui/app/pages/send/send-content/index.js
@@ -1 +1 @@
-export { default } from './send-content.component'
+export { default } from './send-content.container'
diff --git a/ui/app/pages/send/send-content/send-content.component.js b/ui/app/pages/send/send-content/send-content.component.js
index d799806c7..c08a018da 100644
--- a/ui/app/pages/send/send-content/send-content.component.js
+++ b/ui/app/pages/send/send-content/send-content.component.js
@@ -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>
+ )
+ }
}
diff --git a/ui/app/pages/send/send-content/send-content.container.js b/ui/app/pages/send/send-content/send-content.container.js
new file mode 100644
index 000000000..a0732fc20
--- /dev/null
+++ b/ui/app/pages/send/send-content/send-content.container.js
@@ -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)
diff --git a/ui/app/pages/send/send-content/send-to-row/index.js b/ui/app/pages/send/send-content/send-to-row/index.js
deleted file mode 100644
index 121f15148..000000000
--- a/ui/app/pages/send/send-content/send-to-row/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './send-to-row.container'
diff --git a/ui/app/pages/send/send-content/send-to-row/send-to-row-README.md b/ui/app/pages/send/send-content/send-to-row/send-to-row-README.md
deleted file mode 100644
index e69de29bb..000000000
--- a/ui/app/pages/send/send-content/send-to-row/send-to-row-README.md
+++ /dev/null
diff --git a/ui/app/pages/send/send-content/send-to-row/send-to-row.component.js b/ui/app/pages/send/send-content/send-to-row/send-to-row.component.js
deleted file mode 100644
index 9baf327c1..000000000
--- a/ui/app/pages/send/send-content/send-to-row/send-to-row.component.js
+++ /dev/null
@@ -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>
- )
- }
-
-}
diff --git a/ui/app/pages/send/send-content/send-to-row/send-to-row.container.js b/ui/app/pages/send/send-content/send-to-row/send-to-row.container.js
deleted file mode 100644
index 2cbe9fcd0..000000000
--- a/ui/app/pages/send/send-content/send-to-row/send-to-row.container.js
+++ /dev/null
@@ -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))
- },
- }
-}
diff --git a/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-component.test.js b/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-component.test.js
deleted file mode 100644
index c180d97f1..000000000
--- a/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-component.test.js
+++ /dev/null
@@ -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' ]
- )
- })
- })
-})
diff --git a/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-container.test.js b/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-container.test.js
deleted file mode 100644
index bb8702e9a..000000000
--- a/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-container.test.js
+++ /dev/null
@@ -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'
- )
- })
- })
-
- })
-
-})
diff --git a/ui/app/pages/send/send-content/tests/send-content-component.test.js b/ui/app/pages/send/send-content/tests/send-content-component.test.js
index d172423ab..451d2ea53 100644
--- a/ui/app/pages/send/send-content/tests/send-content-component.test.js
+++ b/ui/app/pages/send/send-content/tests/send-content-component.test.js
@@ -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)
})
})
diff --git a/ui/app/pages/send/send-header/send-header.component.js b/ui/app/pages/send/send-header/send-header.component.js
index 76e35494a..5bc76fcd3 100644
--- a/ui/app/pages/send/send-header/send-header.component.js
+++ b/ui/app/pages/send/send-header/send-header.component.js
@@ -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')}
/>
)
}
diff --git a/ui/app/pages/send/send-header/send-header.selectors.js b/ui/app/pages/send/send-header/send-header.selectors.js
index d7c9d3766..2c0a907d8 100644
--- a/ui/app/pages/send/send-header/send-header.selectors.js
+++ b/ui/app/pages/send/send-header/send-header.selectors.js
@@ -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) {
diff --git a/ui/app/pages/send/send-header/tests/send-header-selectors.test.js b/ui/app/pages/send/send-header/tests/send-header-selectors.test.js
index e0c6a3ab3..d22845f84 100644
--- a/ui/app/pages/send/send-header/tests/send-header-selectors.test.js
+++ b/ui/app/pages/send/send-header/tests/send-header-selectors.test.js
@@ -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' ])
})
})
diff --git a/ui/app/pages/send/send.component.js b/ui/app/pages/send/send.component.js
index 5f0c9c9f2..9cdf75536 100644
--- a/ui/app/pages/send/send.component.js
+++ b/ui/app/pages/send/send.component.js
@@ -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} />,
+ ]
+ }
+
}
diff --git a/ui/app/pages/send/send.container.js b/ui/app/pages/send/send.container.js
index 69adbb765..0863c60d4 100644
--- a/ui/app/pages/send/send.container.js
+++ b/ui/app/pages/send/send.container.js
@@ -24,16 +24,25 @@ 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,
updateGasData,
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 || ''))
+ }
+ }
+ },
}
}
diff --git a/ui/app/pages/send/send.scss b/ui/app/pages/send/send.scss
index e69de29bb..9b95f1b39 100644
--- a/ui/app/pages/send/send.scss
+++ b/ui/app/pages/send/send.scss
@@ -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;
+ }
+ }
+}
diff --git a/ui/app/pages/send/send.selectors.js b/ui/app/pages/send/send.selectors.js
index d4035df28..ed2917020 100644
--- a/ui/app/pages/send/send.selectors.js
+++ b/ui/app/pages/send/send.selectors.js
@@ -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
diff --git a/ui/app/pages/send/send.utils.js b/ui/app/pages/send/send.utils.js
index 4acc174f9..daf61bc1b 100644
--- a/ui/app/pages/send/send.utils.js
+++ b/ui/app/pages/send/send.utils.js
@@ -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)}`
+}
diff --git a/ui/app/pages/send/tests/send-component.test.js b/ui/app/pages/send/tests/send-component.test.js
index 81955cc1d..5b7cafed5 100644
--- a/ui/app/pages/send/tests/send-component.test.js
+++ b/ui/app/pages/send/tests/send-component.test.js
@@ -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',
+ })
+ })
+ })
})
diff --git a/ui/app/pages/send/tests/send-container.test.js b/ui/app/pages/send/tests/send-container.test.js
index 131c42f59..f4142bc2d 100644
--- a/ui/app/pages/send/tests/send-container.test.js
+++ b/ui/app/pages/send/tests/send-container.test.js
@@ -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',
})
})
diff --git a/ui/app/pages/send/tests/send-selectors-test-data.js b/ui/app/pages/send/tests/send-selectors-test-data.js
index cff26a191..54a494b63 100644
--- a/ui/app/pages/send/tests/send-selectors-test-data.js
+++ b/ui/app/pages/send/tests/send-selectors-test-data.js
@@ -60,6 +60,7 @@ module.exports = {
{
'address': '0x06195827297c7a80a443b6894d3bdb8824b43896',
'name': 'Address Book Account 1',
+ 'chainId': '3',
},
],
'tokens': [
diff --git a/ui/app/pages/send/tests/send-selectors.test.js b/ui/app/pages/send/tests/send-selectors.test.js
index ccc126795..e199aa97e 100644
--- a/ui/app/pages/send/tests/send-selectors.test.js
+++ b/ui/app/pages/send/tests/send-selectors.test.js
@@ -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',
},
]
)
diff --git a/ui/app/pages/send/to-autocomplete/to-autocomplete.js b/ui/app/pages/send/to-autocomplete/to-autocomplete.js
index 328a5b62b..8ad579958 100644
--- a/ui/app/pages/send/to-autocomplete/to-autocomplete.js
+++ b/ui/app/pages/send/to-autocomplete/to-autocomplete.js
@@ -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(),
])
}
diff --git a/ui/app/pages/settings/contact-list-tab/add-contact/add-contact.component.js b/ui/app/pages/settings/contact-list-tab/add-contact/add-contact.component.js
new file mode 100644
index 000000000..871b2128b
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/add-contact/add-contact.component.js
@@ -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>
+ )
+ }
+}
diff --git a/ui/app/pages/settings/contact-list-tab/add-contact/add-contact.container.js b/ui/app/pages/settings/contact-list-tab/add-contact/add-contact.container.js
new file mode 100644
index 000000000..0a0fc450c
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/add-contact/add-contact.container.js
@@ -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)
diff --git a/ui/app/pages/settings/contact-list-tab/add-contact/index.js b/ui/app/pages/settings/contact-list-tab/add-contact/index.js
new file mode 100644
index 000000000..ce73025a3
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/add-contact/index.js
@@ -0,0 +1 @@
+export { default } from './add-contact.container'
diff --git a/ui/app/pages/settings/contact-list-tab/contact-list-tab.component.js b/ui/app/pages/settings/contact-list-tab/contact-list-tab.component.js
new file mode 100644
index 000000000..f7a01d672
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/contact-list-tab.component.js
@@ -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>
+ )
+ }
+}
diff --git a/ui/app/pages/settings/contact-list-tab/contact-list-tab.container.js b/ui/app/pages/settings/contact-list-tab/contact-list-tab.container.js
new file mode 100644
index 000000000..2c7139b5d
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/contact-list-tab.container.js
@@ -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)
diff --git a/ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js b/ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js
new file mode 100644
index 000000000..e9c2fed6f
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js
@@ -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>
+ )
+ }
+}
diff --git a/ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js b/ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js
new file mode 100644
index 000000000..8841ff791
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js
@@ -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)
diff --git a/ui/app/pages/settings/contact-list-tab/edit-contact/index.js b/ui/app/pages/settings/contact-list-tab/edit-contact/index.js
new file mode 100644
index 000000000..fe5ee206a
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/edit-contact/index.js
@@ -0,0 +1 @@
+export { default } from './edit-contact.container'
diff --git a/ui/app/pages/settings/contact-list-tab/index.js b/ui/app/pages/settings/contact-list-tab/index.js
new file mode 100644
index 000000000..c09e9787b
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/index.js
@@ -0,0 +1 @@
+export { default } from './contact-list-tab.container'
diff --git a/ui/app/pages/settings/contact-list-tab/index.scss b/ui/app/pages/settings/contact-list-tab/index.scss
new file mode 100644
index 000000000..c7e99095f
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/index.scss
@@ -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
+ }
+}
diff --git a/ui/app/pages/settings/contact-list-tab/my-accounts/index.js b/ui/app/pages/settings/contact-list-tab/my-accounts/index.js
new file mode 100644
index 000000000..13a7a9cbf
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/my-accounts/index.js
@@ -0,0 +1 @@
+export { default } from './my-accounts.container'
diff --git a/ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.component.js b/ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.component.js
new file mode 100644
index 000000000..f43b59e07
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.component.js
@@ -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>
+ )
+ }
+}
diff --git a/ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.container.js b/ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.container.js
new file mode 100644
index 000000000..6380c9d4c
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/my-accounts/my-accounts.container.js
@@ -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)
diff --git a/ui/app/pages/settings/contact-list-tab/view-contact/index.js b/ui/app/pages/settings/contact-list-tab/view-contact/index.js
new file mode 100644
index 000000000..78bf19d18
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/view-contact/index.js
@@ -0,0 +1 @@
+export { default } from './view-contact.container'
diff --git a/ui/app/pages/settings/contact-list-tab/view-contact/view-contact.component.js b/ui/app/pages/settings/contact-list-tab/view-contact/view-contact.component.js
new file mode 100644
index 000000000..4f37b853b
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/view-contact/view-contact.component.js
@@ -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>
+ )
+ }
+}
diff --git a/ui/app/pages/settings/contact-list-tab/view-contact/view-contact.container.js b/ui/app/pages/settings/contact-list-tab/view-contact/view-contact.container.js
new file mode 100644
index 000000000..b1196d936
--- /dev/null
+++ b/ui/app/pages/settings/contact-list-tab/view-contact/view-contact.container.js
@@ -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)
diff --git a/ui/app/pages/settings/index.js b/ui/app/pages/settings/index.js
index 44a9ffa63..d2dd7f795 100644
--- a/ui/app/pages/settings/index.js
+++ b/ui/app/pages/settings/index.js
@@ -1 +1 @@
-export { default } from './settings.component'
+export { default } from './settings.container'
diff --git a/ui/app/pages/settings/index.scss b/ui/app/pages/settings/index.scss
index d98a48c2f..73f36806d 100644
--- a/ui/app/pages/settings/index.scss
+++ b/ui/app/pages/settings/index.scss
@@ -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 {
diff --git a/ui/app/pages/settings/settings.component.js b/ui/app/pages/settings/settings.component.js
index 7f2045244..79f383dc4 100644
--- a/ui/app/pages/settings/settings.component.js
+++ b/ui/app/pages/settings/settings.component.js
@@ -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
+
+ let subheaderText
- return pathname !== NETWORKS_ROUTE && (
+ 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)}
/>
@@ -149,6 +194,41 @@ class SettingsPage extends PureComponent {
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}
/>
</Switch>
diff --git a/ui/app/pages/settings/settings.container.js b/ui/app/pages/settings/settings.container.js
new file mode 100644
index 000000000..79b191483
--- /dev/null
+++ b/ui/app/pages/settings/settings.container.js
@@ -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)
diff --git a/ui/app/selectors/selectors.js b/ui/app/selectors/selectors.js
index 56591b7b0..0cf382d2c 100644
--- a/ui/app/selectors/selectors.js
+++ b/ui/app/selectors/selectors.js
@@ -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) {
diff --git a/ui/app/selectors/tests/selectors-test-data.js b/ui/app/selectors/tests/selectors-test-data.js
new file mode 100644
index 000000000..54a494b63
--- /dev/null
+++ b/ui/app/selectors/tests/selectors-test-data.js
@@ -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 },
+ },
+}
diff --git a/ui/app/selectors/tests/selectors.test.js b/ui/app/selectors/tests/selectors.test.js
new file mode 100644
index 000000000..5560b9833
--- /dev/null
+++ b/ui/app/selectors/tests/selectors.test.js
@@ -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',
+ },
+ ],
+ )
+ })
+ })
+
+})
diff --git a/ui/app/store/actions.js b/ui/app/store/actions.js
index f02cdd0fa..72d5a1788 100644
--- a/ui/app/store/actions.js
+++ b/ui/app/store/actions.js
@@ -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))
}
}
diff --git a/yarn.lock b/yarn.lock
index 32614f248..cebcf1742 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"