mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-22 09:57:02 +01:00
Address book send plus contact list (#6914)
* Style Send Header * Move Send to-row to send view and restyle * Add "Recents" group to select recipient view * Rename SendToRow to AddRecipient * Basic UI and Layout * New ENSInput component * wip - fuzzy search for input * small refactor * Add Dialog * contact list initial * initial error on invalid address * clean up edit * Click to open modal * Create AddToAddressBookModal component * Modal styling and layout * modal i18n * Add to Addressbook * ens wip * ens wip * ENS Resolution * Reset input * Send to explicit address * Happy Path Complete * Add back error checking * Reset send-to when emptying input * Add back warning object * Fix linter * Fix unit test #1 - fix import paths * Remove dead tests * One more to go * Fix all unit tests * add unit test for reducers and actions * test rendering AddRecipient * Add tests for dialog boxes in AddRecipient * Add test for validating * Fix linter * Fix e2e tests * Token send e2e fix * Style View Contact * Style edit-contact * Fix e2e * Fix from-import-beta-ui e2e spec * Make section header say "add recipient” by default * Auto-focus add recipient input * Update placeholder text * Update input title font size * Auto advance to next step if user paste a valid address * Ellipsify address when recipient is selected * Fix app header background color on desktop * Give each form row a margin of 16px * Use .container/.component naming pattern for ens-input * Auto-focus on input when add to addressbook modal is opened; Save on Enter * Fix and add unit test * Fix selectors name in e2e tests * Correct e2e test token amount for address-book-send changes * Adds e2e test for editing a transaction * Delete test/integration/lib/send-new-ui.js * Add tests for amount max button and high value error on send screen to test/e2e/metamask-ui.spec.js * lint and revert to address as object keys * add chainId based on current network to address book entry * fix test * only display contacts for the current network * Improve ENS message when not found on current network * Add error to indicate when network does not support ENS * bump gaba * address book, resolve comments * Move contact-list to its own component * De-duplicate getaddressbook selector and refactor name selection logic in contact-list-tab/ * Use contact-list component in contact-list-tab.component (i.e. in settings) * Improve/fix settings headers for popup and browser views * Lint fixes related to address book updates * Add 'My accounts' page to settings address book * Update add new contact button in settings to match floating circular design * Improve styles of view contact page * Improve styles and labels of the add-contact.component * Further lint fixes related to address book updates * Update unit tests as per address book updates * Ensure that contact list groups are sorted alphabetically * Refactor settings component to use a container for connection to redux; allow display of addressbook name in settings header * Decouple ens-input.component from send context * Add ens resolution to add contact screen in settings * Switching networks when an ens address is shown on send form removes the ens address. * Resolve send screen search for ensAddress to matching address book entry if it exists * Show resolved ens icon and address if exists (settings: add-contact.component) * Make the displayed and copied address in view-contact.component the checksummed address * Default alias state prop in AddToAddressBookModal to empty string * Use keyCode to detect enter key in AddToAddressBookModal * Ensure add-contact component properly updates after QR code detection * Fix display of all recents after clicking 'Load More' in contact list * Fix send screen contact searching after network switching * Code cleanup related to address book changes * Update unit tests for address book changes * Update ENS name not found on network message * Add ens registration error message * Cancel on edit mode takes user back to view screen * Adds support for memo to settings contact list view and edit screens * Modify designs of edit and view contact in popup environment * Update settings content list UX to show split columns in fullscreen and proper internal navigation * Correct background address book API usages in UI
This commit is contained in:
parent
1fd3dc9ecf
commit
e9c7df28ed
@ -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"
|
||||
},
|
||||
|
4
app/images/check-green-solid.svg
Normal file
4
app/images/check-green-solid.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.833374 10C0.833374 4.9374 4.93743 0.833344 10 0.833344C15.0626 0.833344 19.1667 4.9374 19.1667 10C19.1667 15.0626 15.0626 19.1667 10 19.1667C4.93743 19.1667 0.833374 15.0626 0.833374 10Z" fill="#28A745"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.4256 6.70245C14.7511 7.02789 14.7511 7.55553 14.4256 7.88097L9.25303 13.2976C8.9276 13.6231 8.39996 13.6231 8.07452 13.2976L5.57452 10.7976C5.24909 10.4722 5.24909 9.94456 5.57452 9.61912C5.89996 9.29368 6.4276 9.29368 6.75303 9.61912L8.66378 11.5299L13.2471 6.70245C13.5725 6.37702 14.1002 6.37702 14.4256 6.70245Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 745 B |
4
app/images/close-gray.svg
Executable file
4
app/images/close-gray.svg
Executable file
@ -0,0 +1,4 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.14917" y="1.09723" width="1.34076" height="15.4188" rx="0.670381" transform="rotate(-45 0.14917 1.09723)" fill="#A1A5B3"/>
|
||||
<rect x="0.94812" y="11.8508" width="1.34076" height="15.4188" rx="0.670381" transform="rotate(-135 0.94812 11.8508)" fill="#A1A5B3"/>
|
||||
</svg>
|
After Width: | Height: | Size: 372 B |
7
app/images/qr-blue.svg
Normal file
7
app/images/qr-blue.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.32 8H8.63997L8.63997 16H16.32V8ZM8.63997 6C7.57958 6 6.71997 6.89543 6.71997 8V16C6.71997 17.1046 7.57958 18 8.63997 18H16.32C17.3804 18 18.24 17.1046 18.24 16V8C18.24 6.89543 17.3804 6 16.32 6H8.63997Z" fill="#037DD6"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.32 1C16.32 0.447715 16.7498 0 17.28 0H21.12C22.7106 0 24 1.34315 24 3V7C24 7.55228 23.5702 8 23.04 8C22.5098 8 22.08 7.55228 22.08 7V3C22.08 2.44772 21.6502 2 21.12 2H17.28C16.7498 2 16.32 1.55228 16.32 1Z" fill="#037DD6"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.04 16C23.5702 16 24 16.4477 24 17L24 21C24 22.6569 22.7106 24 21.12 24L17.28 24C16.7498 24 16.32 23.5523 16.32 23C16.32 22.4477 16.7498 22 17.28 22L21.12 22C21.6502 22 22.08 21.5523 22.08 21L22.08 17C22.08 16.4477 22.5098 16 23.04 16Z" fill="#037DD6"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.67999 23C7.67999 23.5523 7.25019 24 6.71999 24L2.87999 24C1.28941 24 -6.563e-06 22.6569 -6.42394e-06 21L-6.08824e-06 17C-6.04189e-06 16.4477 0.429801 16 0.959994 16C1.49019 16 1.91999 16.4477 1.91999 17L1.91999 21C1.91999 21.5523 2.3498 22 2.87999 22L6.71999 22C7.25019 22 7.67999 22.4477 7.67999 23Z" fill="#037DD6"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.96 8C0.429807 8 5.87108e-08 7.55228 1.31134e-07 7L6.55671e-07 3C8.72941e-07 1.34315 1.28942 1.69087e-07 2.88 3.77666e-07L6.72 8.81222e-07C7.25019 9.50748e-07 7.68 0.447716 7.68 1C7.68 1.55229 7.25019 2 6.72 2L2.88 2C2.34981 2 1.92 2.44772 1.92 3L1.92 7C1.92 7.55229 1.49019 8 0.96 8Z" fill="#037DD6"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
4
app/images/search-black.svg
Normal file
4
app/images/search-black.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.16667 3.33341C5.94501 3.33341 3.33334 5.94509 3.33334 9.16675C3.33334 12.3884 5.94501 15.0001 9.16667 15.0001C12.3883 15.0001 15 12.3884 15 9.16675C15 5.94509 12.3883 3.33341 9.16667 3.33341ZM1.66667 9.16675C1.66667 5.02461 5.02454 1.66675 9.16667 1.66675C13.3088 1.66675 16.6667 5.02461 16.6667 9.16675C16.6667 13.3089 13.3088 16.6667 9.16667 16.6667C5.02454 16.6667 1.66667 13.3089 1.66667 9.16675Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.2857 13.2858C13.6112 12.9604 14.1388 12.9604 14.4642 13.2858L18.0892 16.9108C18.4147 17.2363 18.4147 17.7639 18.0892 18.0893C17.7638 18.4148 17.2362 18.4148 16.9107 18.0893L13.2857 14.4643C12.9603 14.1389 12.9603 13.6113 13.2857 13.2858Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 883 B |
@ -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),
|
||||
|
@ -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",
|
||||
|
@ -119,7 +119,8 @@
|
||||
"addressBook": [
|
||||
{
|
||||
"address": "0xc42edfcc21ed14dda456aa0756c153f7985d8813",
|
||||
"name": ""
|
||||
"name": "",
|
||||
"chainId": 4
|
||||
}
|
||||
],
|
||||
"selectedTokenAddress": "0x108cf70c7d384c552f42c07c41c0e1e46d77ea0d",
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
@ -31,3 +31,11 @@ concurrently --kill-others \
|
||||
--success first \
|
||||
'yarn ganache:start' \
|
||||
'sleep 5 && mocha test/e2e/from-import-ui.spec'
|
||||
|
||||
export GANACHE_ARGS="$GANACHE_ARGS --deterministic --account=0x53CB0AB5226EEBF4D872113D98332C1555DC304443BEE1CF759D15798D3C55A9,25000000000000000000"
|
||||
concurrently --kill-others \
|
||||
--names 'ganache,e2e' \
|
||||
--prefix '[{time}][{name}]' \
|
||||
--success first \
|
||||
'npm run ganache:start' \
|
||||
'sleep 5 && mocha test/e2e/send-edit.spec'
|
||||
|
288
test/e2e/send-edit.spec.js
Normal file
288
test/e2e/send-edit.spec.js
Normal file
@ -0,0 +1,288 @@
|
||||
const path = require('path')
|
||||
const assert = require('assert')
|
||||
const webdriver = require('selenium-webdriver')
|
||||
const { By, Key, until } = webdriver
|
||||
const {
|
||||
delay,
|
||||
buildChromeWebDriver,
|
||||
buildFirefoxWebdriver,
|
||||
installWebExt,
|
||||
getExtensionIdChrome,
|
||||
getExtensionIdFirefox,
|
||||
} = require('./func')
|
||||
const {
|
||||
checkBrowserForConsoleErrors,
|
||||
closeAllWindowHandlesExcept,
|
||||
verboseReportOnFailure,
|
||||
findElement,
|
||||
findElements,
|
||||
} = require('./helpers')
|
||||
const fetchMockResponses = require('./fetch-mocks.js')
|
||||
|
||||
|
||||
describe('Using MetaMask with an existing account', function () {
|
||||
let extensionId
|
||||
let driver
|
||||
|
||||
const testSeedPhrase = 'forum vessel pink push lonely enact gentle tail admit parrot grunt dress'
|
||||
const tinyDelayMs = 200
|
||||
const regularDelayMs = 1000
|
||||
const largeDelayMs = regularDelayMs * 2
|
||||
|
||||
this.timeout(0)
|
||||
this.bail(true)
|
||||
|
||||
before(async function () {
|
||||
let extensionUrl
|
||||
switch (process.env.SELENIUM_BROWSER) {
|
||||
case 'chrome': {
|
||||
const extensionPath = path.resolve('dist/chrome')
|
||||
driver = buildChromeWebDriver(extensionPath)
|
||||
extensionId = await getExtensionIdChrome(driver)
|
||||
await delay(regularDelayMs)
|
||||
extensionUrl = `chrome-extension://${extensionId}/home.html`
|
||||
break
|
||||
}
|
||||
case 'firefox': {
|
||||
const extensionPath = path.resolve('dist/firefox')
|
||||
driver = buildFirefoxWebdriver()
|
||||
await installWebExt(driver, extensionPath)
|
||||
await delay(regularDelayMs)
|
||||
extensionId = await getExtensionIdFirefox(driver)
|
||||
extensionUrl = `moz-extension://${extensionId}/home.html`
|
||||
break
|
||||
}
|
||||
}
|
||||
// Depending on the state of the application built into the above directory (extPath) and the value of
|
||||
// METAMASK_DEBUG we will see different post-install behaviour and possibly some extra windows. Here we
|
||||
// are closing any extraneous windows to reset us to a single window before continuing.
|
||||
const [tab1] = await driver.getAllWindowHandles()
|
||||
await closeAllWindowHandlesExcept(driver, [tab1])
|
||||
await driver.switchTo().window(tab1)
|
||||
await driver.get(extensionUrl)
|
||||
})
|
||||
|
||||
beforeEach(async function () {
|
||||
await driver.executeScript(
|
||||
'window.origFetch = window.fetch.bind(window);' +
|
||||
'window.fetch = ' +
|
||||
'(...args) => { ' +
|
||||
'if (args[0] === "https://ethgasstation.info/json/ethgasAPI.json") { return ' +
|
||||
'Promise.resolve({ json: () => Promise.resolve(JSON.parse(\'' + fetchMockResponses.ethGasBasic + '\')) }); } else if ' +
|
||||
'(args[0] === "https://ethgasstation.info/json/predictTable.json") { return ' +
|
||||
'Promise.resolve({ json: () => Promise.resolve(JSON.parse(\'' + fetchMockResponses.ethGasPredictTable + '\')) }); } else if ' +
|
||||
'(args[0].match(/chromeextensionmm/)) { return ' +
|
||||
'Promise.resolve({ json: () => Promise.resolve(JSON.parse(\'' + fetchMockResponses.metametrics + '\')) }); } else if ' +
|
||||
'(args[0] === "https://dev.blockscale.net/api/gasexpress.json") { return ' +
|
||||
'Promise.resolve({ json: () => Promise.resolve(JSON.parse(\'' + fetchMockResponses.gasExpress + '\')) }); } ' +
|
||||
'return window.origFetch(...args); };' +
|
||||
'function cancelInfuraRequest(requestDetails) {' +
|
||||
'console.log("Canceling: " + requestDetails.url);' +
|
||||
'return {' +
|
||||
'cancel: true' +
|
||||
'};' +
|
||||
' }' +
|
||||
'window.chrome && window.chrome.webRequest && window.chrome.webRequest.onBeforeRequest.addListener(' +
|
||||
'cancelInfuraRequest,' +
|
||||
'{urls: ["https://*.infura.io/*"]},' +
|
||||
'["blocking"]' +
|
||||
');'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(async function () {
|
||||
if (process.env.SELENIUM_BROWSER === 'chrome') {
|
||||
const errors = await checkBrowserForConsoleErrors(driver)
|
||||
if (errors.length) {
|
||||
const errorReports = errors.map(err => err.message)
|
||||
const errorMessage = `Errors found in browser console:\n${errorReports.join('\n')}`
|
||||
console.error(new Error(errorMessage))
|
||||
}
|
||||
}
|
||||
if (this.currentTest.state === 'failed') {
|
||||
await verboseReportOnFailure(driver, this.currentTest)
|
||||
}
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
await driver.quit()
|
||||
})
|
||||
|
||||
describe('First time flow starting from an existing seed phrase', () => {
|
||||
it('clicks the continue button on the welcome screen', async () => {
|
||||
await findElement(driver, By.css('.welcome-page__header'))
|
||||
const welcomeScreenBtn = await findElement(driver, By.css('.first-time-flow__button'))
|
||||
welcomeScreenBtn.click()
|
||||
await delay(largeDelayMs)
|
||||
})
|
||||
|
||||
it('clicks the "Import Wallet" option', async () => {
|
||||
const customRpcButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Import Wallet')]`))
|
||||
customRpcButton.click()
|
||||
await delay(largeDelayMs)
|
||||
})
|
||||
|
||||
it('clicks the "No thanks" option on the metametrics opt-in screen', async () => {
|
||||
const optOutButton = await findElement(driver, By.css('.btn-default'))
|
||||
optOutButton.click()
|
||||
await delay(largeDelayMs)
|
||||
})
|
||||
|
||||
it('imports a seed phrase', async () => {
|
||||
const [seedTextArea] = await findElements(driver, By.css('textarea.first-time-flow__textarea'))
|
||||
await seedTextArea.sendKeys(testSeedPhrase)
|
||||
await delay(regularDelayMs)
|
||||
|
||||
const [password] = await findElements(driver, By.id('password'))
|
||||
await password.sendKeys('correct horse battery staple')
|
||||
const [confirmPassword] = await findElements(driver, By.id('confirm-password'))
|
||||
confirmPassword.sendKeys('correct horse battery staple')
|
||||
|
||||
const tosCheckBox = await findElement(driver, By.css('.first-time-flow__checkbox'))
|
||||
await tosCheckBox.click()
|
||||
|
||||
const [importButton] = await findElements(driver, By.xpath(`//button[contains(text(), 'Import')]`))
|
||||
await importButton.click()
|
||||
await delay(regularDelayMs)
|
||||
})
|
||||
|
||||
it('clicks through the success screen', async () => {
|
||||
await findElement(driver, By.xpath(`//div[contains(text(), 'Congratulations')]`))
|
||||
const doneButton = await findElement(driver, By.css('button.first-time-flow__button'))
|
||||
await doneButton.click()
|
||||
await delay(regularDelayMs)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Send ETH from inside MetaMask', () => {
|
||||
it('starts a send transaction', async function () {
|
||||
const sendButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Send')]`))
|
||||
await sendButton.click()
|
||||
await delay(regularDelayMs)
|
||||
|
||||
const inputAddress = await findElement(driver, By.css('input[placeholder="Search, public address (0x), or ENS"]'))
|
||||
await inputAddress.sendKeys('0x2f318C334780961FB129D2a6c30D0763d9a5C970')
|
||||
|
||||
const recipientRow = await findElement(driver, By.css('.send__select-recipient-wrapper__group-item'))
|
||||
await recipientRow.click()
|
||||
await delay(regularDelayMs)
|
||||
|
||||
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
|
||||
await inputAmount.sendKeys('1')
|
||||
|
||||
// Set the gas limit
|
||||
const configureGas = await findElement(driver, By.css('.advanced-gas-options-btn'))
|
||||
await configureGas.click()
|
||||
await delay(regularDelayMs)
|
||||
|
||||
const gasModal = await driver.findElement(By.css('span .modal'))
|
||||
|
||||
const [gasPriceInput, gasLimitInput] = await findElements(driver, By.css('.advanced-tab__gas-edit-row__input'))
|
||||
await gasPriceInput.sendKeys(Key.chord(Key.CONTROL, 'a'))
|
||||
await delay(50)
|
||||
|
||||
|
||||
await gasPriceInput.sendKeys(Key.BACK_SPACE)
|
||||
await delay(50)
|
||||
await gasPriceInput.sendKeys(Key.BACK_SPACE)
|
||||
await delay(50)
|
||||
await gasPriceInput.sendKeys('10')
|
||||
await delay(50)
|
||||
await delay(tinyDelayMs)
|
||||
await delay(50)
|
||||
await gasLimitInput.sendKeys(Key.chord(Key.CONTROL, 'a'))
|
||||
await delay(50)
|
||||
|
||||
await gasLimitInput.sendKeys('25000')
|
||||
|
||||
const save = await findElement(driver, By.xpath(`//button[contains(text(), 'Save')]`))
|
||||
await save.click()
|
||||
await driver.wait(until.stalenessOf(gasModal))
|
||||
await delay(regularDelayMs)
|
||||
|
||||
// Continue to next screen
|
||||
const nextScreen = await findElement(driver, By.xpath(`//button[contains(text(), 'Next')]`))
|
||||
await nextScreen.click()
|
||||
await delay(regularDelayMs)
|
||||
})
|
||||
|
||||
it('has correct value and fee on the confirm screen the transaction', async function () {
|
||||
const transactionAmounts = await findElements(driver, By.css('.currency-display-component__text'))
|
||||
const transactionAmount = transactionAmounts[0]
|
||||
assert.equal(await transactionAmount.getText(), '1')
|
||||
|
||||
const transactionFee = transactionAmounts[1]
|
||||
assert.equal(await transactionFee.getText(), '0.00025')
|
||||
})
|
||||
|
||||
it('edits the transaction', async function () {
|
||||
const editButton = await findElement(driver, By.css('.confirm-page-container-header__back-button'))
|
||||
await editButton.click()
|
||||
|
||||
await delay(regularDelayMs)
|
||||
|
||||
const inputAmount = await findElement(driver, By.css('.unit-input__input'))
|
||||
await inputAmount.sendKeys(Key.chord(Key.CONTROL, 'a'))
|
||||
await delay(50)
|
||||
await inputAmount.sendKeys(Key.BACK_SPACE)
|
||||
await delay(50)
|
||||
await inputAmount.sendKeys('2.2')
|
||||
|
||||
const configureGas = await findElement(driver, By.css('.advanced-gas-options-btn'))
|
||||
await configureGas.click()
|
||||
await delay(regularDelayMs)
|
||||
|
||||
const gasModal = await driver.findElement(By.css('span .modal'))
|
||||
|
||||
const [gasPriceInput, gasLimitInput] = await findElements(driver, By.css('.advanced-tab__gas-edit-row__input'))
|
||||
await gasPriceInput.sendKeys(Key.chord(Key.CONTROL, 'a'))
|
||||
await delay(50)
|
||||
|
||||
await gasPriceInput.sendKeys(Key.BACK_SPACE)
|
||||
await delay(50)
|
||||
await gasPriceInput.sendKeys(Key.BACK_SPACE)
|
||||
await delay(50)
|
||||
await gasPriceInput.sendKeys('8')
|
||||
await delay(50)
|
||||
await delay(tinyDelayMs)
|
||||
await delay(50)
|
||||
await gasLimitInput.sendKeys(Key.chord(Key.CONTROL, 'a'))
|
||||
await delay(50)
|
||||
|
||||
await gasLimitInput.sendKeys('100000')
|
||||
|
||||
const save = await findElement(driver, By.xpath(`//button[contains(text(), 'Save')]`))
|
||||
await save.click()
|
||||
await driver.wait(until.stalenessOf(gasModal))
|
||||
await delay(regularDelayMs)
|
||||
|
||||
const nextScreen = await findElement(driver, By.xpath(`//button[contains(text(), 'Next')]`))
|
||||
await nextScreen.click()
|
||||
await delay(regularDelayMs)
|
||||
})
|
||||
|
||||
it('has correct updated value on the confirm screen the transaction', async function () {
|
||||
const transactionAmounts = await findElements(driver, By.css('.currency-display-component__text'))
|
||||
const transactionAmount = transactionAmounts[0]
|
||||
assert.equal(await transactionAmount.getText(), '2.2')
|
||||
|
||||
const transactionFee = transactionAmounts[1]
|
||||
assert.equal(await transactionFee.getText(), '0.0008')
|
||||
})
|
||||
|
||||
it('confirms the transaction', async function () {
|
||||
const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`))
|
||||
await confirmButton.click()
|
||||
await delay(regularDelayMs)
|
||||
})
|
||||
|
||||
it('finds the transaction in the transactions list', async function () {
|
||||
const transactions = await findElements(driver, By.css('.transaction-list-item'))
|
||||
assert.equal(transactions.length, 1)
|
||||
|
||||
const txValues = await findElements(driver, By.css('.transaction-list-item__amount--primary'))
|
||||
assert.equal(txValues.length, 1)
|
||||
assert.ok(/-2.2\s*ETH/.test(await txValues[0].getText()))
|
||||
})
|
||||
})
|
||||
})
|
@ -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')
|
||||
}
|
@ -869,7 +869,7 @@ describe('Actions', () => {
|
||||
})
|
||||
|
||||
it('', () => {
|
||||
const store = mockStore()
|
||||
const store = mockStore({ metamask: devState })
|
||||
store.dispatch(actions.addToAddressBook('test'))
|
||||
assert(addToAddressBookSpy.calledOnce)
|
||||
})
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
114
ui/app/components/app/contact-list/contact-list.component.js
Normal file
114
ui/app/components/app/contact-list/contact-list.component.js
Normal file
@ -0,0 +1,114 @@
|
||||
import React, { PureComponent } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import RecipientGroup from './recipient-group/recipient-group.component'
|
||||
|
||||
export default class ContactList extends PureComponent {
|
||||
static propTypes = {
|
||||
searchForContacts: PropTypes.func,
|
||||
searchForRecents: PropTypes.func,
|
||||
searchForMyAccounts: PropTypes.func,
|
||||
selectRecipient: PropTypes.func,
|
||||
children: PropTypes.node,
|
||||
selectedAddress: PropTypes.string,
|
||||
}
|
||||
|
||||
static contextTypes = {
|
||||
t: PropTypes.func,
|
||||
}
|
||||
|
||||
state = {
|
||||
isShowingAllRecent: false,
|
||||
}
|
||||
|
||||
renderRecents () {
|
||||
const { t } = this.context
|
||||
const { isShowingAllRecent } = this.state
|
||||
const nonContacts = this.props.searchForRecents()
|
||||
|
||||
const showLoadMore = !isShowingAllRecent && nonContacts.length > 2
|
||||
|
||||
return (
|
||||
<div className="send__select-recipient-wrapper__recent-group-wrapper">
|
||||
<RecipientGroup
|
||||
label={t('recents')}
|
||||
items={showLoadMore ? nonContacts.slice(0, 2) : nonContacts}
|
||||
onSelect={this.props.selectRecipient}
|
||||
selectedAddress={this.props.selectedAddress}
|
||||
/>
|
||||
{
|
||||
showLoadMore && (
|
||||
<div
|
||||
className="send__select-recipient-wrapper__recent-group-wrapper__load-more"
|
||||
onClick={() => this.setState({ isShowingAllRecent: true })}
|
||||
>
|
||||
{t('loadMore')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderAddressBook () {
|
||||
const contacts = this.props.searchForContacts()
|
||||
|
||||
const contactGroups = contacts.reduce((acc, contact) => {
|
||||
const firstLetter = contact.name.slice(0, 1).toUpperCase()
|
||||
acc[firstLetter] = acc[firstLetter] || []
|
||||
const bucket = acc[firstLetter]
|
||||
bucket.push(contact)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return Object
|
||||
.entries(contactGroups)
|
||||
.sort(([letter1], [letter2]) => {
|
||||
if (letter1 > letter2) {
|
||||
return 1
|
||||
} else if (letter1 === letter2) {
|
||||
return 0
|
||||
} else if (letter1 < letter2) {
|
||||
return -1
|
||||
}
|
||||
})
|
||||
.map(([letter, groupItems]) => (
|
||||
<RecipientGroup
|
||||
key={`${letter}-contract-group`}
|
||||
label={letter}
|
||||
items={groupItems}
|
||||
onSelect={this.props.selectRecipient}
|
||||
selectedAddress={this.props.selectedAddress}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
renderMyAccounts () {
|
||||
const myAccounts = this.props.searchForMyAccounts()
|
||||
|
||||
return (
|
||||
<RecipientGroup
|
||||
items={myAccounts}
|
||||
onSelect={this.props.selectRecipient}
|
||||
selectedAddress={this.props.selectedAddress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
children,
|
||||
searchForRecents,
|
||||
searchForContacts,
|
||||
searchForMyAccounts,
|
||||
} = this.props
|
||||
|
||||
return (
|
||||
<div className="send__select-recipient-wrapper__list">
|
||||
{ children || null }
|
||||
{ searchForRecents && this.renderRecents() }
|
||||
{ searchForContacts && this.renderAddressBook() }
|
||||
{ searchForMyAccounts && this.renderMyAccounts() }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
1
ui/app/components/app/contact-list/index.js
Normal file
1
ui/app/components/app/contact-list/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './contact-list.component'
|
@ -0,0 +1 @@
|
||||
export { default } from './recipient-group.component'
|
@ -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,
|
||||
}
|
@ -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])
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
@ -0,0 +1 @@
|
||||
export { default } from './add-to-addressbook-modal.container'
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -9,3 +9,5 @@
|
||||
@import 'transaction-confirmed/index';
|
||||
|
||||
@import 'metametrics-opt-in-modal/index';
|
||||
|
||||
@import './add-to-addressbook-modal/index';
|
||||
|
@ -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 || {}
|
||||
|
26
ui/app/components/ui/dialog/dialog.scss
Normal file
26
ui/app/components/ui/dialog/dialog.scss
Normal file
@ -0,0 +1,26 @@
|
||||
.dialog {
|
||||
font-size: .75rem;
|
||||
line-height: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid $black;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
|
||||
&--message {
|
||||
border-color: $Blue-200;
|
||||
color: $Blue-600;
|
||||
background-color: $Blue-000;
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-color: $Red-300;
|
||||
color: $Red-600;
|
||||
background-color: $Red-000;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
border-color: $Orange-300;
|
||||
color: $Orange-600;
|
||||
background-color: $Orange-000;
|
||||
}
|
||||
}
|
26
ui/app/components/ui/dialog/index.js
Normal file
26
ui/app/components/ui/dialog/index.js
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import c from 'classnames'
|
||||
|
||||
export default function Dialog (props) {
|
||||
const { children, type, className, onClick } = props
|
||||
return (
|
||||
<div
|
||||
className={c('dialog', className, {
|
||||
'dialog--message': type === 'message',
|
||||
'dialog--error': type === 'error',
|
||||
'dialog--warning': type === 'warning',
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
{ children }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Dialog.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
type: PropTypes.oneOf(['message', 'error', 'warning']),
|
||||
onClick: PropTypes.func,
|
||||
}
|
@ -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() }
|
||||
|
||||
|
@ -61,6 +61,9 @@ const styles = {
|
||||
...inputLabelBase,
|
||||
fontSize: '.75rem',
|
||||
},
|
||||
inputMultiline: {
|
||||
lineHeight: 'initial !important',
|
||||
},
|
||||
}
|
||||
|
||||
const TextField = props => {
|
||||
|
@ -1,4 +1,5 @@
|
||||
@import '../../../components/ui/button/buttons';
|
||||
@import '../../../components/ui/dialog/dialog';
|
||||
|
||||
@import './footer.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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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: {
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -2,6 +2,8 @@
|
||||
|
||||
@import 'add-token/index';
|
||||
|
||||
@import 'send/send';
|
||||
|
||||
@import 'confirm-add-token/index';
|
||||
|
||||
@import 'settings/index';
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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)),
|
||||
}
|
||||
}
|
@ -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])
|
||||
}
|
@ -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)
|
@ -0,0 +1 @@
|
||||
export { default } from './ens-input.container'
|
1
ui/app/pages/send/send-content/add-recipient/index.js
Normal file
1
ui/app/pages/send/send-content/add-recipient/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './add-recipient.container'
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
@ -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']
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
@ -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', () => {
|
@ -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', () => {
|
@ -1 +1 @@
|
||||
export { default } from './send-content.component'
|
||||
export { default } from './send-content.container'
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
38
ui/app/pages/send/send-content/send-content.container.js
Normal file
38
ui/app/pages/send/send-content/send-content.container.js
Normal file
@ -0,0 +1,38 @@
|
||||
import { connect } from 'react-redux'
|
||||
import SendContent from './send-content.component'
|
||||
import {
|
||||
accountsWithSendEtherInfoSelector,
|
||||
getSendTo,
|
||||
} from '../send.selectors'
|
||||
import {
|
||||
getAddressBook,
|
||||
} from '../../../selectors/selectors'
|
||||
import actions from '../../../store/actions'
|
||||
|
||||
function mapStateToProps (state) {
|
||||
return {
|
||||
to: getSendTo(state),
|
||||
addressBook: getAddressBook(state),
|
||||
ownedAccounts: accountsWithSendEtherInfoSelector(state),
|
||||
}
|
||||
}
|
||||
|
||||
function mapDispatchToProps (dispatch) {
|
||||
return {
|
||||
showAddToAddressBookModal: (recipient) => dispatch(actions.showModal({
|
||||
name: 'ADD_TO_ADDRESSBOOK',
|
||||
recipient,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProps (stateProps, dispatchProps, ownProps) {
|
||||
return {
|
||||
...ownProps,
|
||||
...stateProps,
|
||||
...dispatchProps,
|
||||
showAddToAddressBookModal: () => dispatchProps.showAddToAddressBookModal(stateProps.to),
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(SendContent)
|
@ -1 +0,0 @@
|
||||
export { default } from './send-to-row.container'
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@ -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))
|
||||
},
|
||||
}
|
||||
}
|
@ -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' ]
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
})
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
@ -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')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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' ])
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -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} />,
|
||||
]
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -24,9 +24,16 @@ import {
|
||||
getSendHexDataFeatureFlagState,
|
||||
getSendFromObject,
|
||||
getSendTo,
|
||||
getSendToNickname,
|
||||
getTokenBalance,
|
||||
getQrCodeData,
|
||||
getSendEnsResolution,
|
||||
getSendEnsResolutionError,
|
||||
} from './send.selectors'
|
||||
import {
|
||||
getAddressBook,
|
||||
} from '../../selectors/selectors'
|
||||
import { getTokens } from './send-content/add-recipient/add-recipient.selectors'
|
||||
import {
|
||||
updateSendTo,
|
||||
updateSendTokenBalance,
|
||||
@ -34,6 +41,8 @@ import {
|
||||
setGasTotal,
|
||||
showQrScanner,
|
||||
qrCodeDetected,
|
||||
updateSendEnsResolution,
|
||||
updateSendEnsResolutionError,
|
||||
} from '../../store/actions'
|
||||
import {
|
||||
resetSendState,
|
||||
@ -45,6 +54,9 @@ import {
|
||||
import {
|
||||
calcGasTotal,
|
||||
} from './send.utils.js'
|
||||
import {
|
||||
isValidENSAddress,
|
||||
} from '../../helpers/utils/util'
|
||||
|
||||
import {
|
||||
SEND_ROUTE,
|
||||
@ -72,11 +84,16 @@ function mapStateToProps (state) {
|
||||
selectedAddress: getSelectedAddress(state),
|
||||
selectedToken: getSelectedToken(state),
|
||||
showHexData: getSendHexDataFeatureFlagState(state),
|
||||
ensResolution: getSendEnsResolution(state),
|
||||
ensResolutionError: getSendEnsResolutionError(state),
|
||||
to: getSendTo(state),
|
||||
toNickname: getSendToNickname(state),
|
||||
tokens: getTokens(state),
|
||||
tokenBalance: getTokenBalance(state),
|
||||
tokenContract: getSelectedTokenContract(state),
|
||||
tokenToFiatRate: getSelectedTokenToFiatRate(state),
|
||||
qrCodeData: getQrCodeData(state),
|
||||
addressBook: getAddressBook(state),
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,5 +128,15 @@ function mapDispatchToProps (dispatch) {
|
||||
qrCodeDetected: (data) => dispatch(qrCodeDetected(data)),
|
||||
updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)),
|
||||
fetchBasicGasEstimates: () => dispatch(fetchBasicGasEstimates()),
|
||||
updateSendEnsResolution: (ensResolution) => dispatch(updateSendEnsResolution(ensResolution)),
|
||||
updateSendEnsResolutionError: (message) => dispatch(updateSendEnsResolutionError(message)),
|
||||
updateToNicknameIfNecessary: (to, toNickname, addressBook) => {
|
||||
if (isValidENSAddress(toNickname)) {
|
||||
const addressBookEntry = addressBook.find(({ address}) => to === address) || {}
|
||||
if (!addressBookEntry.name !== toNickname) {
|
||||
dispatch(updateSendTo(to, addressBookEntry.name || ''))
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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)}`
|
||||
}
|
||||
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -60,6 +60,7 @@ module.exports = {
|
||||
{
|
||||
'address': '0x06195827297c7a80a443b6894d3bdb8824b43896',
|
||||
'name': 'Address Book Account 1',
|
||||
'chainId': '3',
|
||||
},
|
||||
],
|
||||
'tokens': [
|
||||
|
@ -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',
|
||||
},
|
||||
]
|
||||
)
|
||||
|
@ -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(),
|
||||
|
||||
])
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
@ -0,0 +1 @@
|
||||
export { default } from './add-contact.container'
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
@ -0,0 +1 @@
|
||||
export { default } from './edit-contact.container'
|
1
ui/app/pages/settings/contact-list-tab/index.js
Normal file
1
ui/app/pages/settings/contact-list-tab/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './contact-list-tab.container'
|
234
ui/app/pages/settings/contact-list-tab/index.scss
Normal file
234
ui/app/pages/settings/contact-list-tab/index.scss
Normal file
@ -0,0 +1,234 @@
|
||||
.address-book-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.address-book {
|
||||
flex: 0.4 1 40%;
|
||||
max-width: 40%;
|
||||
|
||||
@media screen and (max-width: 576px) {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&__entry {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
padding: 16px 14px;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid #dedede;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid #037DD6;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
&__header, &__header--edit {
|
||||
&__name {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-size: 24px;
|
||||
line-height: 34px;
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&__header--edit {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.button {
|
||||
justify-content: flex-end;
|
||||
color: #D73A49;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
@extend %input-2;
|
||||
margin-top: .25rem;
|
||||
|
||||
&--address {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__view-contact {
|
||||
&__text-area-wrapper {
|
||||
height: 96px !important;
|
||||
}
|
||||
|
||||
&__text-area {
|
||||
line-height: initial !important;
|
||||
}
|
||||
|
||||
&__group {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
padding: 1.5rem 1.5rem 0 1.5rem;
|
||||
|
||||
&__label, &__label--capitalized {
|
||||
font-size: .75rem;
|
||||
color: $Grey-500;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
|
||||
&__label--capitalized {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&__value, &__static-address {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
font-size: 1.125rem;
|
||||
color: $Grey-800;
|
||||
word-break: break-word;
|
||||
|
||||
&--address {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&--copy-icon {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__static-address {
|
||||
font-size: 0.875rem;
|
||||
&--copy-icon {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.unit-input__input {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__edit-contact {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
padding-bottom: 0 !important;
|
||||
height: 100%;
|
||||
|
||||
&__content {
|
||||
flex: 1 1 auto;
|
||||
|
||||
> div {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.page-container__footer {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__add-contact {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
padding-bottom: 0 !important;
|
||||
height: 100%;
|
||||
|
||||
&__content {
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__error {
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
left: 8px;
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
&__my-accounts-button {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
cursor: pointer;
|
||||
padding: 15px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(222, 222, 222, 0.2);
|
||||
}
|
||||
|
||||
&__header {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-size: 18px;
|
||||
line-height: 25px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #6A737D;
|
||||
}
|
||||
|
||||
&__caret {
|
||||
display: block;
|
||||
background-image: url(/images/caret-right.svg);
|
||||
width: 30px;
|
||||
opacity: .5;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.address-book-add-button {
|
||||
&__button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 16px;
|
||||
height: 56px;
|
||||
width: 56px;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
border-width: 2px;
|
||||
background: #037DD6;
|
||||
margin-right: 5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0px 2px 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.address-book--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.address-book-contact-content {
|
||||
flex: 0.4 1 40%;
|
||||
|
||||
@media screen and (max-width: 576px) {
|
||||
flex: 1
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from './my-accounts.container'
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
@ -0,0 +1 @@
|
||||
export { default } from './view-contact.container'
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
@ -1 +1 @@
|
||||
export { default } from './settings.component'
|
||||
export { default } from './settings.container'
|
||||
|
@ -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 {
|
||||
|
@ -1,8 +1,6 @@
|
||||
import React, { PureComponent } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Switch, Route, matchPath, withRouter } from 'react-router-dom'
|
||||
import { ENVIRONMENT_TYPE_POPUP } from '../../../../app/scripts/lib/enums'
|
||||
import { getEnvironmentType } from '../../../../app/scripts/lib/util'
|
||||
import TabBar from '../../components/app/tab-bar'
|
||||
import c from 'classnames'
|
||||
import SettingsTab from './settings-tab'
|
||||
@ -10,6 +8,7 @@ import NetworksTab from './networks-tab'
|
||||
import AdvancedTab from './advanced-tab'
|
||||
import InfoTab from './info-tab'
|
||||
import SecurityTab from './security-tab'
|
||||
import ContactListTab from './contact-list-tab'
|
||||
import {
|
||||
DEFAULT_ROUTE,
|
||||
ADVANCED_ROUTE,
|
||||
@ -18,19 +17,28 @@ import {
|
||||
ABOUT_US_ROUTE,
|
||||
SETTINGS_ROUTE,
|
||||
NETWORKS_ROUTE,
|
||||
CONTACT_LIST_ROUTE,
|
||||
CONTACT_ADD_ROUTE,
|
||||
CONTACT_EDIT_ROUTE,
|
||||
CONTACT_VIEW_ROUTE,
|
||||
CONTACT_MY_ACCOUNTS_ROUTE,
|
||||
CONTACT_MY_ACCOUNTS_VIEW_ROUTE,
|
||||
CONTACT_MY_ACCOUNTS_EDIT_ROUTE,
|
||||
} from '../../helpers/constants/routes'
|
||||
|
||||
const ROUTES_TO_I18N_KEYS = {
|
||||
[GENERAL_ROUTE]: 'general',
|
||||
[ADVANCED_ROUTE]: 'advanced',
|
||||
[SECURITY_ROUTE]: 'securityAndPrivacy',
|
||||
[ABOUT_US_ROUTE]: 'about',
|
||||
}
|
||||
|
||||
class SettingsPage extends PureComponent {
|
||||
static propTypes = {
|
||||
location: PropTypes.object,
|
||||
addressName: PropTypes.string,
|
||||
backRoute: PropTypes.string,
|
||||
currentPath: PropTypes.string,
|
||||
history: PropTypes.object,
|
||||
isAddressEntryPage: PropTypes.bool,
|
||||
isPopupView: PropTypes.bool,
|
||||
location: PropTypes.object,
|
||||
pathnameI18nKey: PropTypes.string,
|
||||
initialBreadCrumbRoute: PropTypes.string,
|
||||
breadCrumbTextKey: PropTypes.string,
|
||||
initialBreadCrumbKey: PropTypes.string,
|
||||
t: PropTypes.func,
|
||||
}
|
||||
|
||||
@ -38,35 +46,25 @@ class SettingsPage extends PureComponent {
|
||||
t: PropTypes.func,
|
||||
}
|
||||
|
||||
isCurrentPath (pathname) {
|
||||
return this.props.location.pathname === pathname
|
||||
}
|
||||
|
||||
render () {
|
||||
const { t } = this.context
|
||||
const { history, location } = this.props
|
||||
|
||||
const pathnameI18nKey = ROUTES_TO_I18N_KEYS[location.pathname]
|
||||
const isPopupView = getEnvironmentType(location.href) === ENVIRONMENT_TYPE_POPUP
|
||||
const { history, backRoute, currentPath } = this.props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={c('main-container settings-page', {
|
||||
'settings-page--selected': !this.isCurrentPath(SETTINGS_ROUTE),
|
||||
'settings-page--selected': currentPath !== SETTINGS_ROUTE,
|
||||
})}
|
||||
>
|
||||
<div className="settings-page__header">
|
||||
{
|
||||
!this.isCurrentPath(SETTINGS_ROUTE) && !this.isCurrentPath(NETWORKS_ROUTE) && (
|
||||
currentPath !== SETTINGS_ROUTE && currentPath !== NETWORKS_ROUTE && (
|
||||
<div
|
||||
className="settings-page__back-button"
|
||||
onClick={() => history.push(SETTINGS_ROUTE)}
|
||||
onClick={() => history.push(backRoute)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className="settings-page__header__title">
|
||||
{t(pathnameI18nKey && isPopupView ? pathnameI18nKey : 'settings')}
|
||||
</div>
|
||||
{ this.renderTitle() }
|
||||
<div
|
||||
className="settings-page__close-button"
|
||||
onClick={() => history.push(DEFAULT_ROUTE)}
|
||||
@ -85,19 +83,65 @@ class SettingsPage extends PureComponent {
|
||||
)
|
||||
}
|
||||
|
||||
renderTitle () {
|
||||
const { t } = this.context
|
||||
const { isPopupView, pathnameI18nKey, addressName } = this.props
|
||||
|
||||
let titleText
|
||||
|
||||
if (isPopupView && addressName) {
|
||||
titleText = addressName
|
||||
} else if (pathnameI18nKey && isPopupView) {
|
||||
titleText = t(pathnameI18nKey)
|
||||
} else {
|
||||
titleText = t('settings')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page__header__title">
|
||||
{titleText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderSubHeader () {
|
||||
const { t } = this.context
|
||||
const { location: { pathname } } = this.props
|
||||
const {
|
||||
currentPath,
|
||||
isPopupView,
|
||||
isAddressEntryPage,
|
||||
pathnameI18nKey,
|
||||
addressName,
|
||||
initialBreadCrumbRoute,
|
||||
breadCrumbTextKey,
|
||||
history,
|
||||
initialBreadCrumbKey,
|
||||
} = this.props
|
||||
|
||||
return pathname !== NETWORKS_ROUTE && (
|
||||
let subheaderText
|
||||
|
||||
if (isPopupView && isAddressEntryPage) {
|
||||
subheaderText = t('settings')
|
||||
} else if (initialBreadCrumbKey) {
|
||||
subheaderText = t(initialBreadCrumbKey)
|
||||
} else {
|
||||
subheaderText = t(pathnameI18nKey || 'general')
|
||||
}
|
||||
|
||||
return currentPath !== NETWORKS_ROUTE && (
|
||||
<div className="settings-page__subheader">
|
||||
{t(ROUTES_TO_I18N_KEYS[pathname] || 'general')}
|
||||
<div
|
||||
className={c({ 'settings-page__subheader--link': initialBreadCrumbRoute })}
|
||||
onClick={() => initialBreadCrumbRoute && history.push(initialBreadCrumbRoute)}
|
||||
>{subheaderText}</div>
|
||||
{breadCrumbTextKey && <div><span>{'> '}</span>{t(breadCrumbTextKey)}</div>}
|
||||
{isAddressEntryPage && <div><span>{' > '}</span>{addressName}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderTabs () {
|
||||
const { history, location } = this.props
|
||||
const { history, currentPath } = this.props
|
||||
const { t } = this.context
|
||||
|
||||
return (
|
||||
@ -105,15 +149,16 @@ class SettingsPage extends PureComponent {
|
||||
tabs={[
|
||||
{ content: t('general'), description: t('generalSettingsDescription'), key: GENERAL_ROUTE },
|
||||
{ content: t('advanced'), description: t('advancedSettingsDescription'), key: ADVANCED_ROUTE },
|
||||
{ content: t('contactList'), description: t('contactListDescription'), key: CONTACT_LIST_ROUTE },
|
||||
{ content: t('securityAndPrivacy'), description: t('securitySettingsDescription'), key: SECURITY_ROUTE },
|
||||
{ content: t('networks'), description: t('networkSettingsDescription'), key: NETWORKS_ROUTE },
|
||||
{ content: t('about'), description: t('aboutSettingsDescription'), key: ABOUT_US_ROUTE },
|
||||
]}
|
||||
isActive={key => {
|
||||
if (key === GENERAL_ROUTE && this.isCurrentPath(SETTINGS_ROUTE)) {
|
||||
if (key === GENERAL_ROUTE && currentPath === SETTINGS_ROUTE) {
|
||||
return true
|
||||
}
|
||||
return matchPath(location.pathname, { path: key, exact: true })
|
||||
return matchPath(currentPath, { path: key, exact: true })
|
||||
}}
|
||||
onSelect={key => history.push(key)}
|
||||
/>
|
||||
@ -148,6 +193,41 @@ class SettingsPage extends PureComponent {
|
||||
path={SECURITY_ROUTE}
|
||||
component={SecurityTab}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={CONTACT_LIST_ROUTE}
|
||||
component={ContactListTab}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={CONTACT_ADD_ROUTE}
|
||||
component={ContactListTab}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={CONTACT_MY_ACCOUNTS_ROUTE}
|
||||
component={ContactListTab}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`${CONTACT_EDIT_ROUTE}/:id`}
|
||||
component={ContactListTab}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`${CONTACT_VIEW_ROUTE}/:id`}
|
||||
component={ContactListTab}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`${CONTACT_MY_ACCOUNTS_VIEW_ROUTE}/:id`}
|
||||
component={ContactListTab}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`${CONTACT_MY_ACCOUNTS_EDIT_ROUTE}/:id`}
|
||||
component={ContactListTab}
|
||||
/>
|
||||
<Route
|
||||
component={SettingsTab}
|
||||
/>
|
||||
|
92
ui/app/pages/settings/settings.container.js
Normal file
92
ui/app/pages/settings/settings.container.js
Normal file
@ -0,0 +1,92 @@
|
||||
import Settings from './settings.component'
|
||||
import { compose } from 'recompose'
|
||||
import { connect } from 'react-redux'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { getAddressBookEntryName } from '../../selectors/selectors'
|
||||
import { isValidAddress } from '../../helpers/utils/util'
|
||||
import { ENVIRONMENT_TYPE_POPUP } from '../../../../app/scripts/lib/enums'
|
||||
import { getEnvironmentType } from '../../../../app/scripts/lib/util'
|
||||
|
||||
import {
|
||||
ADVANCED_ROUTE,
|
||||
SECURITY_ROUTE,
|
||||
GENERAL_ROUTE,
|
||||
ABOUT_US_ROUTE,
|
||||
SETTINGS_ROUTE,
|
||||
CONTACT_LIST_ROUTE,
|
||||
CONTACT_ADD_ROUTE,
|
||||
CONTACT_EDIT_ROUTE,
|
||||
CONTACT_VIEW_ROUTE,
|
||||
CONTACT_MY_ACCOUNTS_ROUTE,
|
||||
CONTACT_MY_ACCOUNTS_EDIT_ROUTE,
|
||||
CONTACT_MY_ACCOUNTS_VIEW_ROUTE,
|
||||
} from '../../helpers/constants/routes'
|
||||
|
||||
const ROUTES_TO_I18N_KEYS = {
|
||||
[GENERAL_ROUTE]: 'general',
|
||||
[ADVANCED_ROUTE]: 'advanced',
|
||||
[SECURITY_ROUTE]: 'securityAndPrivacy',
|
||||
[ABOUT_US_ROUTE]: 'about',
|
||||
[CONTACT_LIST_ROUTE]: 'contactList',
|
||||
[CONTACT_ADD_ROUTE]: 'newContact',
|
||||
[CONTACT_EDIT_ROUTE]: 'editContact',
|
||||
[CONTACT_VIEW_ROUTE]: 'viewContact',
|
||||
[CONTACT_MY_ACCOUNTS_ROUTE]: 'myAccounts',
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const { location } = ownProps
|
||||
const { pathname } = location
|
||||
const pathNameTail = pathname.match(/[^/]+$/)[0]
|
||||
|
||||
const isAddressEntryPage = pathNameTail.includes('0x')
|
||||
const isMyAccountsPage = pathname.match('my-accounts')
|
||||
const isAddContactPage = Boolean(pathname.match(CONTACT_ADD_ROUTE))
|
||||
const isEditContactPage = Boolean(pathname.match(CONTACT_EDIT_ROUTE))
|
||||
const isEditMyAccountsContactPage = Boolean(pathname.match(CONTACT_MY_ACCOUNTS_EDIT_ROUTE))
|
||||
|
||||
const isPopupView = getEnvironmentType(location.href) === ENVIRONMENT_TYPE_POPUP
|
||||
const pathnameI18nKey = ROUTES_TO_I18N_KEYS[pathname]
|
||||
|
||||
let backRoute
|
||||
if (isMyAccountsPage && isAddressEntryPage) {
|
||||
backRoute = CONTACT_MY_ACCOUNTS_ROUTE
|
||||
} else if (isEditContactPage) {
|
||||
backRoute = `${CONTACT_VIEW_ROUTE}/${pathNameTail}`
|
||||
} else if (isEditMyAccountsContactPage) {
|
||||
backRoute = `${CONTACT_MY_ACCOUNTS_VIEW_ROUTE}/${pathNameTail}`
|
||||
} else if (isAddressEntryPage || isMyAccountsPage || isAddContactPage) {
|
||||
backRoute = CONTACT_LIST_ROUTE
|
||||
} else {
|
||||
backRoute = SETTINGS_ROUTE
|
||||
}
|
||||
|
||||
let initialBreadCrumbRoute
|
||||
let breadCrumbTextKey
|
||||
let initialBreadCrumbKey
|
||||
if (isMyAccountsPage) {
|
||||
initialBreadCrumbRoute = CONTACT_LIST_ROUTE
|
||||
breadCrumbTextKey = 'myWalletAccounts'
|
||||
initialBreadCrumbKey = ROUTES_TO_I18N_KEYS[initialBreadCrumbRoute]
|
||||
}
|
||||
|
||||
const addressName = getAddressBookEntryName(state, isValidAddress(pathNameTail) ? pathNameTail : '')
|
||||
|
||||
return {
|
||||
isAddressEntryPage,
|
||||
isMyAccountsPage,
|
||||
backRoute,
|
||||
currentPath: pathname,
|
||||
isPopupView,
|
||||
pathnameI18nKey,
|
||||
addressName,
|
||||
initialBreadCrumbRoute,
|
||||
breadCrumbTextKey,
|
||||
initialBreadCrumbKey,
|
||||
}
|
||||
}
|
||||
|
||||
export default compose(
|
||||
withRouter,
|
||||
connect(mapStateToProps)
|
||||
)(Settings)
|
@ -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) {
|
||||
|
232
ui/app/selectors/tests/selectors-test-data.js
Normal file
232
ui/app/selectors/tests/selectors-test-data.js
Normal file
@ -0,0 +1,232 @@
|
||||
module.exports = {
|
||||
'metamask': {
|
||||
'isInitialized': true,
|
||||
'isUnlocked': true,
|
||||
'featureFlags': {'sendHexData': true},
|
||||
'rpcTarget': 'https://rawtestrpc.metamask.io/',
|
||||
'identities': {
|
||||
'0xfdea65c8e26263f6d9a1b5de9555d2931a33b825': {
|
||||
'address': '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825',
|
||||
'name': 'Send Account 1',
|
||||
},
|
||||
'0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb': {
|
||||
'address': '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb',
|
||||
'name': 'Send Account 2',
|
||||
},
|
||||
'0x2f8d4a878cfa04a6e60d46362f5644deab66572d': {
|
||||
'address': '0x2f8d4a878cfa04a6e60d46362f5644deab66572d',
|
||||
'name': 'Send Account 3',
|
||||
},
|
||||
'0xd85a4b6a394794842887b8284293d69163007bbb': {
|
||||
'address': '0xd85a4b6a394794842887b8284293d69163007bbb',
|
||||
'name': 'Send Account 4',
|
||||
},
|
||||
},
|
||||
'cachedBalances': {},
|
||||
'currentBlockGasLimit': '0x4c1878',
|
||||
'currentCurrency': 'USD',
|
||||
'conversionRate': 1200.88200327,
|
||||
'conversionDate': 1489013762,
|
||||
'nativeCurrency': 'ETH',
|
||||
'frequentRpcList': [],
|
||||
'network': '3',
|
||||
'accounts': {
|
||||
'0xfdea65c8e26263f6d9a1b5de9555d2931a33b825': {
|
||||
'code': '0x',
|
||||
'balance': '0x47c9d71831c76efe',
|
||||
'nonce': '0x1b',
|
||||
'address': '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825',
|
||||
},
|
||||
'0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb': {
|
||||
'code': '0x',
|
||||
'balance': '0x37452b1315889f80',
|
||||
'nonce': '0xa',
|
||||
'address': '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb',
|
||||
},
|
||||
'0x2f8d4a878cfa04a6e60d46362f5644deab66572d': {
|
||||
'code': '0x',
|
||||
'balance': '0x30c9d71831c76efe',
|
||||
'nonce': '0x1c',
|
||||
'address': '0x2f8d4a878cfa04a6e60d46362f5644deab66572d',
|
||||
},
|
||||
'0xd85a4b6a394794842887b8284293d69163007bbb': {
|
||||
'code': '0x',
|
||||
'balance': '0x0',
|
||||
'nonce': '0x0',
|
||||
'address': '0xd85a4b6a394794842887b8284293d69163007bbb',
|
||||
},
|
||||
},
|
||||
'addressBook': [
|
||||
{
|
||||
'address': '0x06195827297c7a80a443b6894d3bdb8824b43896',
|
||||
'name': 'Address Book Account 1',
|
||||
'chainId': '3',
|
||||
},
|
||||
],
|
||||
'tokens': [
|
||||
{
|
||||
'address': '0x1a195821297c7a80a433b6894d3bdb8824b43896',
|
||||
'decimals': 18,
|
||||
'symbol': 'ABC',
|
||||
},
|
||||
{
|
||||
'address': '0x8d6b81208414189a58339873ab429b6c47ab92d3',
|
||||
'decimals': 4,
|
||||
'symbol': 'DEF',
|
||||
},
|
||||
{
|
||||
'address': '0xa42084c8d1d9a2198631988579bb36b48433a72b',
|
||||
'decimals': 18,
|
||||
'symbol': 'GHI',
|
||||
},
|
||||
],
|
||||
'tokenExchangeRates': {
|
||||
'def_eth': {
|
||||
rate: 2.0,
|
||||
},
|
||||
'ghi_eth': {
|
||||
rate: 31.01,
|
||||
},
|
||||
},
|
||||
'transactions': {},
|
||||
'selectedAddressTxList': [
|
||||
{
|
||||
'id': 'mockTokenTx1',
|
||||
'txParams': {
|
||||
'to': '0x8d6b81208414189a58339873ab429b6c47ab92d3',
|
||||
},
|
||||
'time': 1700000000000,
|
||||
},
|
||||
{
|
||||
'id': 'mockTokenTx2',
|
||||
'txParams': {
|
||||
'to': '0xafaketokenaddress',
|
||||
},
|
||||
'time': 1600000000000,
|
||||
},
|
||||
{
|
||||
'id': 'mockTokenTx3',
|
||||
'txParams': {
|
||||
'to': '0x8d6b81208414189a58339873ab429b6c47ab92d3',
|
||||
},
|
||||
'time': 1500000000000,
|
||||
},
|
||||
{
|
||||
'id': 'mockEthTx1',
|
||||
'txParams': {
|
||||
'to': '0xd85a4b6a394794842887b8284293d69163007bbb',
|
||||
},
|
||||
'time': 1400000000000,
|
||||
},
|
||||
],
|
||||
'selectedTokenAddress': '0x8d6b81208414189a58339873ab429b6c47ab92d3',
|
||||
'unapprovedMsgs': {
|
||||
'0xabc': { id: 'unapprovedMessage1', 'time': 1650000000000 },
|
||||
'0xdef': { id: 'unapprovedMessage2', 'time': 1550000000000 },
|
||||
'0xghi': { id: 'unapprovedMessage3', 'time': 1450000000000 },
|
||||
},
|
||||
'unapprovedMsgCount': 0,
|
||||
'unapprovedPersonalMsgs': {},
|
||||
'unapprovedPersonalMsgCount': 0,
|
||||
'keyringTypes': [
|
||||
'Simple Key Pair',
|
||||
'HD Key Tree',
|
||||
],
|
||||
'keyrings': [
|
||||
{
|
||||
'type': 'HD Key Tree',
|
||||
'accounts': [
|
||||
'fdea65c8e26263f6d9a1b5de9555d2931a33b825',
|
||||
'c5b8dbac4c1d3f152cdeb400e2313f309c410acb',
|
||||
'2f8d4a878cfa04a6e60d46362f5644deab66572d',
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'Simple Key Pair',
|
||||
'accounts': [
|
||||
'0xd85a4b6a394794842887b8284293d69163007bbb',
|
||||
],
|
||||
},
|
||||
],
|
||||
'selectedAddress': '0xd85a4b6a394794842887b8284293d69163007bbb',
|
||||
'provider': {
|
||||
'type': 'testnet',
|
||||
},
|
||||
'shapeShiftTxList': [
|
||||
{ id: 'shapeShiftTx1', 'time': 1675000000000 },
|
||||
{ id: 'shapeShiftTx2', 'time': 1575000000000 },
|
||||
{ id: 'shapeShiftTx3', 'time': 1475000000000 },
|
||||
],
|
||||
'lostAccounts': [],
|
||||
'send': {
|
||||
'gasLimit': '0xFFFF',
|
||||
'gasPrice': '0xaa',
|
||||
'gasTotal': '0xb451dc41b578',
|
||||
'tokenBalance': 3434,
|
||||
'from': {
|
||||
'address': '0xabcdefg',
|
||||
'balance': '0x5f4e3d2c1',
|
||||
},
|
||||
'to': '0x987fedabc',
|
||||
'amount': '0x080',
|
||||
'memo': '',
|
||||
'errors': {
|
||||
'someError': null,
|
||||
},
|
||||
'maxModeOn': false,
|
||||
'editingTransactionId': 97531,
|
||||
'forceGasMin': true,
|
||||
},
|
||||
'unapprovedTxs': {
|
||||
'4768706228115573': {
|
||||
'id': 4768706228115573,
|
||||
'time': 1487363153561,
|
||||
'status': 'unapproved',
|
||||
'gasMultiplier': 1,
|
||||
'metamaskNetworkId': '3',
|
||||
'txParams': {
|
||||
'from': '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb',
|
||||
'to': '0x18a3462427bcc9133bb46e88bcbe39cd7ef0e761',
|
||||
'value': '0xde0b6b3a7640000',
|
||||
'metamaskId': 4768706228115573,
|
||||
'metamaskNetworkId': '3',
|
||||
'gas': '0x5209',
|
||||
},
|
||||
'gasLimitSpecified': false,
|
||||
'estimatedGas': '0x5209',
|
||||
'txFee': '17e0186e60800',
|
||||
'txValue': 'de0b6b3a7640000',
|
||||
'maxCost': 'de234b52e4a0800',
|
||||
'gasPrice': '4a817c800',
|
||||
},
|
||||
},
|
||||
'currentLocale': 'en',
|
||||
recentBlocks: ['mockBlock1', 'mockBlock2', 'mockBlock3'],
|
||||
},
|
||||
'appState': {
|
||||
'menuOpen': false,
|
||||
'currentView': {
|
||||
'name': 'accountDetail',
|
||||
'detailView': null,
|
||||
'context': '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc',
|
||||
},
|
||||
'accountDetail': {
|
||||
'subview': 'transactions',
|
||||
},
|
||||
'modal': {
|
||||
'modalState': {},
|
||||
'previousModalState': {},
|
||||
},
|
||||
'transForward': true,
|
||||
'isLoading': false,
|
||||
'warning': null,
|
||||
'scrollToBottom': false,
|
||||
'forgottenPassword': null,
|
||||
},
|
||||
'identities': {},
|
||||
'send': {
|
||||
'fromDropdownOpen': false,
|
||||
'toDropdownOpen': false,
|
||||
'errors': { 'someError': null },
|
||||
},
|
||||
}
|
25
ui/app/selectors/tests/selectors.test.js
Normal file
25
ui/app/selectors/tests/selectors.test.js
Normal file
@ -0,0 +1,25 @@
|
||||
import assert from 'assert'
|
||||
import selectors from '../selectors.js'
|
||||
const {
|
||||
getAddressBook,
|
||||
} = selectors
|
||||
import mockState from './selectors-test-data'
|
||||
|
||||
describe('selectors', () => {
|
||||
|
||||
describe('getAddressBook()', () => {
|
||||
it('should return the address book', () => {
|
||||
assert.deepEqual(
|
||||
getAddressBook(mockState),
|
||||
[
|
||||
{
|
||||
address: '0x06195827297c7a80a443b6894d3bdb8824b43896',
|
||||
name: 'Address Book Account 1',
|
||||
chainId: '3',
|
||||
},
|
||||
],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
14
yarn.lock
14
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"
|
||||
|
Loading…
Reference in New Issue
Block a user