1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 09:57:02 +01:00

Redesign approve screen (#7271)

* Redesign approve screen

* Add translations to approve screen components

* Show account in header of approve screen

* Use state prop bool for unlimited vs custom check in edit-approval-permission

* Set option to custom on input change in edit-approval-permission

* Allow setting of approval amount to unlimited in edit-approval-permission

* Fix height of confirm-approval popup

* Ensure decimals prop passted to confirm-approve.component is correct type

* Ensure first param passed to calcTokenValue in confirm-approve.util is the correct type

* Fix e2e test of permission editing

* Remove unused code from edit-approval-permission.container
This commit is contained in:
Dan J Miller 2019-11-05 11:43:48 -03:30 committed by GitHub
parent 99b8f2d544
commit 2673eef3c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1340 additions and 73 deletions

View File

@ -56,6 +56,10 @@
"acceleratingATransaction": {
"message": "* Accelerating a transaction by using a higher gas price increases its chances of getting processed by the network faster, but it is not always guaranteed."
},
"accessAndSpendNotice": {
"message": "$1 may access and spend up to this max amount",
"description": "$1 is the url of the site requesting ability to spend"
},
"accessingYourCamera": {
"message": "Accessing your camera..."
},
@ -113,9 +117,20 @@
"addAcquiredTokens": {
"message": "Add the tokens you've acquired using MetaMask"
},
"allowOriginSpendToken": {
"message": "Allow $1 to spend your $2?",
"description": "$1 is the url of the site and $2 is the symbol of the token they are requesting to spend"
},
"allowWithdrawAndSpend": {
"message": "Allow $1 to withdraw and spend up to the following amount:",
"description": "The url of the site that requested permission to 'withdraw and spend'"
},
"amount": {
"message": "Amount"
},
"amountWithColon": {
"message": "Amount:"
},
"appDescription": {
"message": "An Ethereum Wallet in your Browser",
"description": "The description of the application"
@ -384,6 +399,9 @@
"customRPC": {
"message": "Custom RPC"
},
"customSpendLimit": {
"message": "Custom Spend Limit"
},
"dataBackupFoundInfo": {
"message": "Some of your account data was backed up during a previous installation of MetaMask. This could include your settings, contacts and tokens. Would you like to restore this data now?"
},
@ -444,6 +462,9 @@
"editContact": {
"message": "Edit Contact"
},
"editPermission": {
"message": "Edit Permission"
},
"emailUs": {
"message": "Email us!"
},
@ -486,6 +507,9 @@
"enterAnAlias": {
"message": "Enter an alias"
},
"enterMaxSpendLimit": {
"message": "Enter Max Spend Limit"
},
"enterPassword": {
"message": "Enter password"
},
@ -516,6 +540,9 @@
"faster": {
"message": "Faster"
},
"feeAssociatedRequest": {
"message": "A fee is associated with this request."
},
"fiat": {
"message": "Fiat",
"description": "Exchange type"
@ -533,6 +560,9 @@
"fromShapeShift": {
"message": "From ShapeShift"
},
"functionApprove": {
"message": "Function: Approve"
},
"functionType": {
"message": "Function Type"
},
@ -953,6 +983,9 @@
"privateNetwork": {
"message": "Private Network"
},
"proposedApprovalLimit": {
"message": "Proposed Approval Limit"
},
"qrCode": {
"message": "Show QR Code"
},
@ -1212,6 +1245,13 @@
"speedUpTransaction": {
"message": "Speed up this transaction"
},
"spendLimitPermission": {
"message": "Spend limit permission"
},
"spendLimitRequestedBy": {
"message": "Spend limit requested by $1",
"description": "Origin of the site requesting the spend limit"
},
"switchNetworks": {
"message": "Switch Networks"
},
@ -1308,6 +1348,9 @@
"to": {
"message": "To"
},
"toWithColon": {
"message": "To:"
},
"toETHviaShapeShift": {
"message": "$1 to ETH via ShapeShift",
"description": "system will fill in deposit type in start of message"
@ -1382,6 +1425,10 @@
"message": "We had trouble loading your token balances. You can view them ",
"description": "Followed by a link (here) to view token balances"
},
"trustSiteApprovePermission": {
"message": "Do you trust this site? By granting this permission, youre allowing $1 to withdraw your $2 and automate transactions for you.",
"description": "$1 is the url requesting permission and $2 is the symbol of the currency that the request is for"
},
"tryAgain": {
"message": "Try again"
},
@ -1409,6 +1456,9 @@
"unknownCameraError": {
"message": "There was an error while trying to access your camera. Please try again..."
},
"unlimited": {
"message": "Unlimited"
},
"unlock": {
"message": "Unlock"
},

View File

@ -0,0 +1,3 @@
<svg width="20" height="16" viewBox="0 0 20 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 8C9.1875 8 11 6.21875 11 4C11 1.8125 9.1875 0 7 0C4.78125 0 3 1.8125 3 4C3 6.21875 4.78125 8 7 8ZM9.78125 9H9.25C8.5625 9.34375 7.8125 9.5 7 9.5C6.1875 9.5 5.40625 9.34375 4.71875 9H4.1875C1.875 9 0 10.9062 0 13.2188V14.5C0 15.3438 0.65625 16 1.5 16H12.5C13.3125 16 14 15.3438 14 14.5V13.2188C14 10.9062 12.0938 9 9.78125 9ZM19.875 5L19 4.125C18.875 3.96875 18.625 3.96875 18.5 4.125L15.2188 7.375L13.7812 5.9375C13.6562 5.78125 13.4062 5.78125 13.25 5.9375L12.375 6.8125C12.25 6.9375 12.25 7.1875 12.375 7.34375L14.9375 9.90625C15.0938 10.0625 15.3125 10.0625 15.4688 9.90625L19.875 5.53125C20.0312 5.375 20.0312 5.15625 19.875 5Z" fill="#6A737D"/>
</svg>

After

Width:  |  Height:  |  Size: 769 B

View File

@ -1124,8 +1124,8 @@ describe('MetaMask', function () {
await driver.switchTo().window(dapp)
await delay(tinyDelayMs)
const transferTokens = await findElement(driver, By.xpath(`//button[contains(text(), 'Approve Tokens')]`))
await transferTokens.click()
const approveTokens = await findElement(driver, By.xpath(`//button[contains(text(), 'Approve Tokens')]`))
await approveTokens.click()
await driver.switchTo().window(extension)
await delay(regularDelayMs)
@ -1143,31 +1143,22 @@ describe('MetaMask', function () {
})
it('displays the token approval data', async () => {
const dataTab = await findElement(driver, By.xpath(`//li[contains(text(), 'Data')]`))
dataTab.click()
const fullTxDataButton = await findElement(driver, By.css('.confirm-approve-content__view-full-tx-button'))
await fullTxDataButton.click()
await delay(regularDelayMs)
const functionType = await findElement(driver, By.css('.confirm-page-container-content__function-type'))
const functionType = await findElement(driver, By.css('.confirm-approve-content__data .confirm-approve-content__small-text'))
const functionTypeText = await functionType.getText()
assert.equal(functionTypeText, 'Approve')
assert.equal(functionTypeText, 'Function: Approve')
const confirmDataDiv = await findElement(driver, By.css('.confirm-page-container-content__data-box'))
const confirmDataDiv = await findElement(driver, By.css('.confirm-approve-content__data__data-block'))
const confirmDataText = await confirmDataDiv.getText()
assert(confirmDataText.match(/0x095ea7b30000000000000000000000009bc5baf874d2da8d216ae9f137804184ee5afef4/))
const detailsTab = await findElement(driver, By.xpath(`//li[contains(text(), 'Details')]`))
detailsTab.click()
await delay(regularDelayMs)
const approvalWarning = await findElement(driver, By.css('.confirm-page-container-warning__warning'))
const approvalWarningText = await approvalWarning.getText()
assert(approvalWarningText.match(/By approving this/))
await delay(regularDelayMs)
})
it('opens the gas edit modal', async () => {
const configureGas = await driver.wait(until.elementLocated(By.css('.confirm-detail-row__header-text--edit')))
await configureGas.click()
const editButtons = await findElements(driver, By.css('.confirm-approve-content__small-blue-text.cursor-pointer'))
await editButtons[0].click()
await delay(regularDelayMs)
gasModal = await driver.findElement(By.css('span .modal'))
@ -1198,14 +1189,34 @@ describe('MetaMask', function () {
await save.click()
await driver.wait(until.stalenessOf(gasModal))
const gasFeeInputs = await findElements(driver, By.css('.confirm-detail-row__primary'))
assert.equal(await gasFeeInputs[0].getText(), '0.0006')
const gasFeeInEth = await findElement(driver, By.css('.confirm-approve-content__transaction-details-content__secondary-fee'))
assert.equal(await gasFeeInEth.getText(), '0.0006')
})
it('shows the correct recipient', async function () {
const senderToRecipientDivs = await findElements(driver, By.css('.sender-to-recipient__name'))
const recipientDiv = senderToRecipientDivs[1]
assert.equal(await recipientDiv.getText(), '0x9bc5...fEF4')
it('edits the permission', async () => {
const editButtons = await findElements(driver, By.css('.confirm-approve-content__small-blue-text.cursor-pointer'))
await editButtons[1].click()
await delay(regularDelayMs)
const permissionModal = await driver.findElement(By.css('span .modal'))
const radioButtons = await findElements(driver, By.css('.edit-approval-permission__edit-section__radio-button'))
await radioButtons[1].click()
const customInput = await findElement(driver, By.css('input'))
await delay(50)
await customInput.sendKeys('5')
await delay(regularDelayMs)
const saveButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Save')]`))
await saveButton.click()
await delay(regularDelayMs)
await driver.wait(until.stalenessOf(permissionModal))
const permissionInfo = await findElements(driver, By.css('.confirm-approve-content__medium-text'))
const amountDiv = permissionInfo[0]
assert.equal(await amountDiv.getText(), '5 TST')
})
it('submits the transaction', async function () {
@ -1221,7 +1232,7 @@ describe('MetaMask', function () {
}, 10000)
const txValues = await findElements(driver, By.css('.transaction-list-item__amount--primary'))
await driver.wait(until.elementTextMatches(txValues[0], /-7\s*TST/))
await driver.wait(until.elementTextMatches(txValues[0], /-5\s*TST/))
const txStatuses = await findElements(driver, By.css('.transaction-list-item__action'))
await driver.wait(until.elementTextMatches(txStatuses[0], /Approve/))
})
@ -1305,9 +1316,13 @@ describe('MetaMask', function () {
})
it('shows the correct recipient', async function () {
const senderToRecipientDivs = await findElements(driver, By.css('.sender-to-recipient__name'))
const recipientDiv = senderToRecipientDivs[1]
assert.equal(await recipientDiv.getText(), 'Account 2')
const fullTxDataButton = await findElement(driver, By.css('.confirm-approve-content__view-full-tx-button'))
await fullTxDataButton.click()
await delay(regularDelayMs)
const permissionInfo = await findElements(driver, By.css('.confirm-approve-content__medium-text'))
const recipientDiv = permissionInfo[1]
assert.equal(await recipientDiv.getText(), '0x2f318C33...C970')
})
it('submits the transaction', async function () {

View File

@ -4,6 +4,7 @@
.confirm-page-container-content {
overflow-y: auto;
height: 100%;
flex: 1;
&__error-container {

View File

@ -5,6 +5,8 @@ import {
ENVIRONMENT_TYPE_NOTIFICATION,
} from '../../../../../../app/scripts/lib/enums'
import NetworkDisplay from '../../network-display'
import Identicon from '../../../ui/identicon'
import { addressSlicer } from '../../../../helpers/utils/util'
export default class ConfirmPageContainerHeader extends Component {
static contextTypes = {
@ -12,13 +14,15 @@ export default class ConfirmPageContainerHeader extends Component {
}
static propTypes = {
accountAddress: PropTypes.string,
showAccountInHeader: PropTypes.bool,
showEdit: PropTypes.bool,
onEdit: PropTypes.func,
children: PropTypes.node,
}
renderTop () {
const { onEdit, showEdit } = this.props
const { onEdit, showEdit, accountAddress, showAccountInHeader } = this.props
const windowType = window.METAMASK_UI_TYPE
const isFullScreen = windowType !== ENVIRONMENT_TYPE_NOTIFICATION &&
windowType !== ENVIRONMENT_TYPE_POPUP
@ -29,7 +33,8 @@ export default class ConfirmPageContainerHeader extends Component {
return (
<div className="confirm-page-container-header__row">
<div
{ !showAccountInHeader
? <div
className="confirm-page-container-header__back-button-container"
style={{
visibility: showEdit ? 'initial' : 'hidden',
@ -45,6 +50,22 @@ export default class ConfirmPageContainerHeader extends Component {
{ this.context.t('edit') }
</span>
</div>
: null
}
{ showAccountInHeader
? <div className="confirm-page-container-header__address-container">
<div className="confirm-page-container-header__address-identicon">
<Identicon
address={accountAddress}
diameter={24}
/>
</div>
<div className="confirm-page-container-header__address">
{ addressSlicer(accountAddress) }
</div>
</div>
: null
}
{ !isFullScreen && <NetworkDisplay /> }
</div>
)

View File

@ -9,6 +9,7 @@
border-bottom: 1px solid $geyser;
padding: 4px 13px 4px 13px;
flex: 0 0 auto;
align-items: center;
}
&__back-button-container {
@ -28,4 +29,16 @@
font-weight: 400;
padding-left: 5px;
}
&__address-container {
display: flex;
align-items: center;
margin-top: 2px;
margin-bottom: 2px;
}
&__address {
margin-left: 6px;
font-size: 14px;
}
}

View File

@ -19,6 +19,8 @@ export default class ConfirmPageContainer extends Component {
subtitleComponent: PropTypes.node,
title: PropTypes.string,
titleComponent: PropTypes.node,
hideSenderToRecipient: PropTypes.bool,
showAccountInHeader: PropTypes.bool,
// Sender to Recipient
fromAddress: PropTypes.string,
fromName: PropTypes.string,
@ -104,6 +106,8 @@ export default class ConfirmPageContainer extends Component {
lastTx,
ofText,
requestsWaitingText,
hideSenderToRecipient,
showAccountInHeader,
} = this.props
const renderAssetImage = contentComponent || (!contentComponent && !identiconAddress)
@ -124,8 +128,12 @@ export default class ConfirmPageContainer extends Component {
<ConfirmPageContainerHeader
showEdit={showEdit}
onEdit={() => onEdit()}
showAccountInHeader={showAccountInHeader}
accountAddress={fromAddress}
>
<SenderToRecipient
{ hideSenderToRecipient
? null
: <SenderToRecipient
senderName={fromName}
senderAddress={fromAddress}
recipientName={toName}
@ -134,6 +142,7 @@ export default class ConfirmPageContainer extends Component {
recipientNickname={toNickname}
assetImage={renderAssetImage ? assetImage : undefined}
/>
}
</ConfirmPageContainerHeader>
{
contentComponent || (

View File

@ -5,3 +5,9 @@
@import 'confirm-detail-row/index';
@import 'confirm-page-container-navigation/index';
.page-container {
&__content-component-wrapper {
height: 100%;
}
}

View File

@ -1,10 +1,13 @@
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import Button from '../../ui/button'
import classnames from 'classnames'
export default class Modal extends PureComponent {
static propTypes = {
children: PropTypes.node,
contentClass: PropTypes.string,
containerClass: PropTypes.string,
// Header text
headerText: PropTypes.string,
onClose: PropTypes.func,
@ -36,10 +39,12 @@ export default class Modal extends PureComponent {
onCancel,
cancelType,
cancelText,
contentClass,
containerClass,
} = this.props
return (
<div className="modal-container">
<div className={classnames('modal-container', containerClass)}>
{
headerText && (
<div className="modal-container__header">
@ -53,7 +58,7 @@ export default class Modal extends PureComponent {
</div>
)
}
<div className="modal-container__content">
<div className={classnames('modal-container__content', contentClass)}>
{ children }
</div>
<div className="modal-container__footer">

View File

@ -0,0 +1,170 @@
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import Modal from '../../modal'
import Identicon from '../../../ui/identicon'
import TextField from '../../../ui/text-field'
import classnames from 'classnames'
export default class EditApprovalPermission extends PureComponent {
static propTypes = {
hideModal: PropTypes.func.isRequired,
selectedIdentity: PropTypes.object,
tokenAmount: PropTypes.string,
customTokenAmount: PropTypes.string,
tokenSymbol: PropTypes.string,
tokenBalance: PropTypes.string,
setCustomAmount: PropTypes.func,
origin: PropTypes.string,
}
static contextTypes = {
t: PropTypes.func,
}
state = {
customSpendLimit: this.props.customTokenAmount,
selectedOptionIsUnlimited: !this.props.customTokenAmount,
}
renderModalContent () {
const { t } = this.context
const {
hideModal,
selectedIdentity,
tokenAmount,
tokenSymbol,
tokenBalance,
customTokenAmount,
origin,
} = this.props
const { name, address } = selectedIdentity || {}
const { selectedOptionIsUnlimited } = this.state
return (
<div className="edit-approval-permission">
<div className="edit-approval-permission__header">
<div className="edit-approval-permission__title">
{ t('editPermission') }
</div>
<div
className="edit-approval-permission__header__close"
onClick={() => hideModal()}
/>
</div>
<div className="edit-approval-permission__account-info">
<div className="edit-approval-permission__account-info__account">
<Identicon
address={address}
diameter={32}
/>
<div className="edit-approval-permission__account-info__name">{ name }</div>
<div>{ t('balance') }</div>
</div>
<div className="edit-approval-permission__account-info__balance">
{`${tokenBalance} ${tokenSymbol}`}
</div>
</div>
<div className="edit-approval-permission__edit-section">
<div className="edit-approval-permission__edit-section__title">
{ t('spendLimitPermission') }
</div>
<div className="edit-approval-permission__edit-section__description">
{ t('allowWithdrawAndSpend', [origin]) }
</div>
<div className="edit-approval-permission__edit-section__option">
<div
className="edit-approval-permission__edit-section__radio-button"
onClick={() => this.setState({ selectedOptionIsUnlimited: true })}
>
<div className={classnames({
'edit-approval-permission__edit-section__radio-button-outline': !selectedOptionIsUnlimited,
'edit-approval-permission__edit-section__radio-button-outline--selected': selectedOptionIsUnlimited,
})} />
<div className="edit-approval-permission__edit-section__radio-button-fill" />
{ selectedOptionIsUnlimited && <div className="edit-approval-permission__edit-section__radio-button-dot" />}
</div>
<div className="edit-approval-permission__edit-section__option-text">
<div className={classnames({
'edit-approval-permission__edit-section__option-label': !selectedOptionIsUnlimited,
'edit-approval-permission__edit-section__option-label--selected': selectedOptionIsUnlimited,
})}>
{
tokenAmount < tokenBalance
? t('proposedApprovalLimit')
: t('unlimited')
}
</div>
<div className="edit-approval-permission__edit-section__option-description" >
{ t('spendLimitRequestedBy', [origin]) }
</div>
<div className="edit-approval-permission__edit-section__option-value" >
{`${tokenAmount} ${tokenSymbol}`}
</div>
</div>
</div>
<div className="edit-approval-permission__edit-section__option">
<div
className="edit-approval-permission__edit-section__radio-button"
onClick={() => this.setState({ selectedOptionIsUnlimited: false })}
>
<div className={classnames({
'edit-approval-permission__edit-section__radio-button-outline': selectedOptionIsUnlimited,
'edit-approval-permission__edit-section__radio-button-outline--selected': !selectedOptionIsUnlimited,
})} />
<div className="edit-approval-permission__edit-section__radio-button-fill" />
{ !selectedOptionIsUnlimited && <div className="edit-approval-permission__edit-section__radio-button-dot" />}
</div>
<div className="edit-approval-permission__edit-section__option-text">
<div className={classnames({
'edit-approval-permission__edit-section__option-label': selectedOptionIsUnlimited,
'edit-approval-permission__edit-section__option-label--selected': !selectedOptionIsUnlimited,
})}>
{ t('customSpendLimit') }
</div>
<div className="edit-approval-permission__edit-section__option-description" >
{ t('enterMaxSpendLimit') }
</div>
<div className="edit-approval-permission__edit-section__option-input" >
<TextField
type="number"
min="0"
placeholder={ `${customTokenAmount || tokenAmount} ${tokenSymbol}` }
onChange={(event) => {
this.setState({ customSpendLimit: event.target.value })
if (selectedOptionIsUnlimited) {
this.setState({ selectedOptionIsUnlimited: false })
}
}}
fullWidth
margin="dense"
value={ this.state.customSpendLimit }
/>
</div>
</div>
</div>
</div>
</div>
)
}
render () {
const { t } = this.context
const { setCustomAmount, hideModal, customTokenAmount } = this.props
const { selectedOptionIsUnlimited, customSpendLimit } = this.state
return (
<Modal
onSubmit={() => {
setCustomAmount(!selectedOptionIsUnlimited ? customSpendLimit : '')
hideModal()
}}
submitText={t('save')}
submitType="primary"
contentClass="edit-approval-permission-modal-content"
containerClass="edit-approval-permission-modal-container"
submitDisabled={ (customSpendLimit === customTokenAmount) && !selectedOptionIsUnlimited }
>
{ this.renderModalContent() }
</Modal>
)
}
}

View File

@ -0,0 +1,18 @@
import { connect } from 'react-redux'
import { compose } from 'recompose'
import withModalProps from '../../../../helpers/higher-order-components/with-modal-props'
import EditApprovalPermission from './edit-approval-permission.component'
import { getSelectedIdentity } from '../../../../selectors/selectors'
const mapStateToProps = (state) => {
const modalStateProps = state.appState.modal.modalState.props || {}
return {
selectedIdentity: getSelectedIdentity(state),
...modalStateProps,
}
}
export default compose(
withModalProps,
connect(mapStateToProps)
)(EditApprovalPermission)

View File

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

View File

@ -0,0 +1,167 @@
.edit-approval-permission {
width: 100%;
&__header,
&__account-info {
display: flex;
justify-content: center;
align-items: center;
position: relative;
border-bottom: 1px solid #d2d8dd;
}
&__header {
padding: 24px;
&__close {
position: absolute;
right: 24px;
background-image: url("/images/close-gray.svg");
width: .75rem;
height: .75rem;
cursor: pointer;
}
}
&__title {
font-weight: bold;
font-size: 18px;
line-height: 25px;
}
&__account-info {
justify-content: space-between;
padding: 8px 24px;
&__account,
&__balance {
font-weight: normal;
font-size: 14px;
color: #24292E;
}
&__account {
display: flex;
align-items: center;
}
&__name {
margin-left: 8px;
margin-right: 8px;
}
&__balance {
color: #6A737D;
}
}
&__edit-section {
padding: 24px;
&__title {
font-weight: bold;
font-size: 14px;
line-height: 20px;
color: #24292E;
}
&__description {
font-weight: normal;
font-size: 12px;
line-height: 17px;
color: #6A737D;
margin-top: 8px;
}
&__option {
display: flex;
align-items: flex-start;
margin-top: 20px;
}
&__radio-button {
width: 18px;
}
&__option-text {
display: flex;
flex-direction: column;
}
&__option-label,
&__option-label--selected {
font-weight: normal;
font-size: 14px;
line-height: 20px;
color: #474B4D;
}
&__option-label--selected {
color: #037DD6;
}
&__option-description {
font-weight: normal;
font-size: 12px;
line-height: 17px;
color: #6A737D;
margin-top: 8px;
margin-bottom: 6px;
}
&__option-value {
font-weight: normal;
font-size: 18px;
line-height: 25px;
color: #24292E;
}
&__radio-button {
position: relative;
width: 18px;
height: 18px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 4px;
}
&__radio-button-outline,
&__radio-button-outline--selected {
width: 18px;
height: 18px;
background: #DADCDD;
border-radius: 9px;
position: absolute;
}
&__radio-button-outline--selected {
background: #037DD6;
}
&__radio-button-fill {
width: 14px;
height: 14px;
background: white;
border-radius: 7px;
position: absolute;
}
&__radio-button-dot {
width: 8px;
height: 8px;
background: #037DD6;
border-radius: 4px;
position: absolute;
}
}
}
.edit-approval-permission-modal-content {
padding: 0px;
}
.edit-approval-permission-modal-container {
max-height: 550px;
width: 100%;
}

View File

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

View File

@ -28,6 +28,7 @@ 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'
import EditApprovalPermission from './edit-approval-permission'
const modalContainerBaseStyle = {
transform: 'translate3d(-50%, 0, 0px)',
@ -304,6 +305,31 @@ const MODALS = {
},
},
EDIT_APPROVAL_PERMISSION: {
contents: h(EditApprovalPermission),
mobileModalStyle: {
width: '95vw',
height: '100vh',
top: '50px',
transform: 'none',
left: '0',
right: '0',
margin: '0 auto',
},
laptopModalStyle: {
width: 'auto',
height: '0px',
top: '80px',
left: '0px',
transform: 'none',
margin: '0 auto',
position: 'relative',
},
contentStyle: {
borderRadius: '8px',
},
},
TRANSACTION_CONFIRMED: {
disableBackdropClick: true,
contents: h(TransactionConfirmed),

View File

@ -141,11 +141,11 @@
}
.cursor-pointer:hover {
transform: scale(1.1);
transform: scale(1.05);
}
.cursor-pointer:active {
transform: scale(.95);
transform: scale(.97);
}
.cursor-disabled {

View File

@ -15,6 +15,7 @@ export default function withTokenTracker (WrappedComponent) {
this.state = {
string: '',
symbol: '',
balance: '',
error: null,
}
@ -78,8 +79,8 @@ export default function withTokenTracker (WrappedComponent) {
if (!this.tracker.running) {
return
}
const [{ string, symbol }] = tokens
this.setState({ string, symbol, error: null })
const [{ string, symbol, balance }] = tokens
this.setState({ string, symbol, error: null, balance })
}
removeListeners () {
@ -91,13 +92,13 @@ export default function withTokenTracker (WrappedComponent) {
}
render () {
const { string, symbol, error } = this.state
const { balance, string, symbol, error } = this.state
return (
<WrappedComponent
{ ...this.props }
string={string}
symbol={symbol}
tokenTrackerBalance={balance}
error={error}
/>
)

View File

@ -128,6 +128,11 @@ export function calcTokenAmount (value, decimals) {
return new BigNumber(String(value)).div(multiplier)
}
export function calcTokenValue (value, decimals) {
const multiplier = Math.pow(10, Number(decimals || 0))
return new BigNumber(String(value)).times(multiplier)
}
export function getTokenValue (tokenParams = []) {
const valueData = tokenParams.find(param => param.name === '_value')
return valueData && valueData.value

View File

@ -0,0 +1,223 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames'
import Identicon from '../../../components/ui/identicon'
import {
addressSummary,
} from '../../../helpers/utils/util'
export default class ConfirmApproveContent extends Component {
static contextTypes = {
t: PropTypes.func,
}
static propTypes = {
amount: PropTypes.string,
txFeeTotal: PropTypes.string,
tokenAmount: PropTypes.string,
customTokenAmount: PropTypes.string,
tokenSymbol: PropTypes.string,
siteImage: PropTypes.string,
tokenAddress: PropTypes.string,
showCustomizeGasModal: PropTypes.func,
showEditApprovalPermissionModal: PropTypes.func,
origin: PropTypes.string,
setCustomAmount: PropTypes.func,
tokenBalance: PropTypes.string,
data: PropTypes.string,
toAddress: PropTypes.string,
fiatTransactionTotal: PropTypes.string,
ethTransactionTotal: PropTypes.string,
}
state = {
showFullTxDetails: false,
}
renderApproveContentCard ({
symbol,
title,
showEdit,
onEditClick,
content,
footer,
noBorder,
}) {
return (
<div className={classnames({
'confirm-approve-content__card': !noBorder,
'confirm-approve-content__card--no-border': noBorder,
})}>
<div className="confirm-approve-content__card-header">
<div className="confirm-approve-content__card-header__symbol">{ symbol }</div>
<div className="confirm-approve-content__card-header__title">{ title }</div>
{ showEdit && <div
className="confirm-approve-content__small-blue-text cursor-pointer"
onClick={() => onEditClick()}
>Edit</div> }
</div>
<div className="confirm-approve-content__card-content">
{ content }
</div>
{ footer }
</div>
)
}
// TODO: Add "Learn Why" with link to the feeAssociatedRequest text
renderTransactionDetailsContent () {
const { t } = this.context
const {
ethTransactionTotal,
fiatTransactionTotal,
} = this.props
return (
<div className="confirm-approve-content__transaction-details-content">
<div className="confirm-approve-content__small-text">
{ t('feeAssociatedRequest') }
</div>
<div className="confirm-approve-content__transaction-details-content__fee">
<div className="confirm-approve-content__transaction-details-content__primary-fee">
{ fiatTransactionTotal }
</div>
<div className="confirm-approve-content__transaction-details-content__secondary-fee">
{ ethTransactionTotal }
</div>
</div>
</div>
)
}
renderPermissionContent () {
const { t } = this.context
const { customTokenAmount, tokenAmount, tokenSymbol, origin, toAddress } = this.props
return (
<div className="flex-column">
<div className="confirm-approve-content__small-text">{ t('accessAndSpendNotice', [origin]) }</div>
<div className="flex-row">
<div className="confirm-approve-content__label">{ t('amountWithColon') }</div>
<div className="confirm-approve-content__medium-text">{ `${customTokenAmount || tokenAmount} ${tokenSymbol}` }</div>
</div>
<div className="flex-row">
<div className="confirm-approve-content__label">{ t('toWithColon') }</div>
<div className="confirm-approve-content__medium-text">{ addressSummary(toAddress) }</div>
</div>
</div>
)
}
renderDataContent () {
const { t } = this.context
const { data } = this.props
return (
<div className="flex-column">
<div className="confirm-approve-content__small-text">{ t('functionApprove') }</div>
<div className="confirm-approve-content__small-text confirm-approve-content__data__data-block">{ data }</div>
</div>
)
}
render () {
const { t } = this.context
const {
siteImage,
tokenAmount,
customTokenAmount,
origin,
tokenSymbol,
showCustomizeGasModal,
showEditApprovalPermissionModal,
setCustomAmount,
tokenBalance,
} = this.props
const { showFullTxDetails } = this.state
return (
<div className={classnames('confirm-approve-content', {
'confirm-approve-content--full': showFullTxDetails,
})}>
<div className="confirm-approve-content__identicon-wrapper">
<Identicon
className="confirm-approve-content__identicon"
diameter={48}
address={origin}
image={siteImage}
/>
</div>
<div className="confirm-approve-content__title">
{ t('allowOriginSpendToken', [origin, tokenSymbol]) }
</div>
<div className="confirm-approve-content__description">
{ t('trustSiteApprovePermission', [origin, tokenSymbol]) }
</div>
<div
className="confirm-approve-content__edit-submission-button-container"
>
<div
className="confirm-approve-content__medium-link-text cursor-pointer"
onClick={() => showEditApprovalPermissionModal({ customTokenAmount, tokenAmount, tokenSymbol, setCustomAmount, tokenBalance, origin })}
>
{ t('editPermission') }
</div>
</div>
<div className="confirm-approve-content__card-wrapper">
{this.renderApproveContentCard({
symbol: <i className="fa fa-tag" />,
title: 'Transaction Fee',
showEdit: true,
onEditClick: showCustomizeGasModal,
content: this.renderTransactionDetailsContent(),
noBorder: !showFullTxDetails,
footer: <div
className="confirm-approve-content__view-full-tx-button-wrapper"
onClick={() => this.setState({ showFullTxDetails: !this.state.showFullTxDetails })}
>
<div className="confirm-approve-content__view-full-tx-button cursor-pointer">
<div className="confirm-approve-content__small-blue-text">
View full transaction details
</div>
<i className={classnames({
'fa fa-caret-up': showFullTxDetails,
'fa fa-caret-down': !showFullTxDetails,
})} />
</div>
</div>,
})}
</div>
{
showFullTxDetails
? (
<div className="confirm-approve-content__full-tx-content">
<div className="confirm-approve-content__permission">
{this.renderApproveContentCard({
symbol: <img src="/images/user-check.svg" />,
title: 'Permission',
content: this.renderPermissionContent(),
showEdit: true,
onEditClick: () => showEditApprovalPermissionModal({
customTokenAmount,
tokenAmount,
tokenSymbol,
tokenBalance,
setCustomAmount,
}),
})}
</div>
<div className="confirm-approve-content__data">
{this.renderApproveContentCard({
symbol: <i className="fa fa-file" />,
title: 'Data',
content: this.renderDataContent(),
noBorder: true,
})}
</div>
</div>
)
: null
}
</div>
)
}
}

View File

@ -0,0 +1 @@
export { default } from './confirm-approve-content.component'

View File

@ -0,0 +1,306 @@
.confirm-approve-content {
display: flex;
flex-flow: column;
align-items: center;
width: 100%;
height: 100%;
font-family: Roboto;
font-style: normal;
&__identicon-wrapper {
display: flex;
width: 100%;
justify-content: center;
margin-top: 22px;
padding-left: 24px;
padding-right: 24px;
}
&__full-tx-content {
display: flex;
flex-flow: column;
align-items: center;
width: 390px;
font-family: Roboto;
font-style: normal;
padding-left: 24px;
padding-right: 24px;
}
&__card-wrapper {
width: 100%;
}
&__title {
font-weight: normal;
font-size: 24px;
line-height: 34px;
width: 100%;
display: flex;
justify-content: center;
text-align: center;
margin-top: 22px;
padding-left: 24px;
padding-right: 24px;
}
&__description {
font-weight: normal;
font-size: 14px;
line-height: 20px;
margin-top: 16px;
margin-bottom: 16px;
color: #6A737D;
text-align: center;
padding-left: 24px;
padding-right: 24px;
}
&__card,
&__card--no-border {
display: flex;
flex-flow: column;
border-bottom: 1px solid #D2D8DD;
position: relative;
padding-left: 24px;
padding-right: 24px;
&__bold-text {
font-weight: bold;
font-size: 14px;
line-height: 20px;
}
&__thin-text {
font-weight: normal;
font-size: 12px;
line-height: 17px;
color: #6A737D;
}
}
&__card--no-border {
border-bottom: none;
}
&__card-header {
display: flex;
flex-flow: row;
margin-top: 20px;
align-items: center;
position: relative;
&__symbol {
width: auto;
}
&__symbol--aligned {
width: 100%;
}
&__title, &__title-value {
font-weight: bold;
font-size: 14px;
line-height: 20px;
}
&__title {
width: 100%;
margin-left: 16px;
}
&__title--aligned {
margin-left: 27px;
position: absolute;
width: auto;
}
}
&__card-content {
margin-top: 6px;
margin-bottom: 12px;
}
&__card-content--aligned {
margin-left: 42px;
}
&__transaction-total-symbol {
width: 16px;
display: flex;
justify-content: center;
align-items: center;
height: 16px;
&__x {
display: flex;
justify-content: center;
align-items: center;
div {
width: 22px;
height: 2px;
background: #037DD6;
position: absolute;
}
div:first-of-type {
transform: rotate(45deg);
}
div:last-of-type {
transform: rotate(-45deg);
}
}
&__circle {
width: 14px;
height: 14px;
border: 2px solid #037DD6;
border-radius: 50%;
background: white;
position: absolute;
}
}
&__transaction-details-content {
display: flex;
flex-flow: row;
justify-content: space-between;
.confirm-approve-content__small-text {
width: 160px;
}
&__fee {
display: flex;
flex-flow: column;
align-items: flex-end;
text-align: right;
}
&__primary-fee {
font-weight: bold;
font-size: 18px;
line-height: 25px;
color: #000000;
}
&__secondary-fee {
font-weight: normal;
font-size: 14px;
line-height: 20px;
color: #8C8E94;
}
}
&__view-full-tx-button-wrapper {
display: flex;
flex-flow: row;
margin-bottom: 16px;
justify-content: center;
i {
margin-left: 6px;
display: flex;
color: #3099f2;
align-items: center;
}
}
&__view-full-tx-button {
display: flex;
flex-flow: row;
}
&__edit-submission-button-container {
display: flex;
flex-flow: row;
padding-top: 15px;
padding-bottom: 30px;
border-bottom: 1px solid #D2D8DD;
width: 100%;
justify-content: center;
padding-left: 24px;
padding-right: 24px;
}
&__large-text {
font-size: 18px;
line-height: 25px;
color: #24292E;
}
&__medium-link-text {
font-size: 14px;
line-height: 20px;
font-weight: 500;
color: #037DD6;
}
&__medium-text,
&__label {
font-weight: normal;
font-size: 14px;
line-height: 20px;
color: #24292E;
}
&__label {
font-weight: bold;
margin-right: 4px;
}
&__small-text, &__small-blue-text, &__info-row {
font-weight: normal;
font-size: 12px;
line-height: 17px;
color: #6A737D;
}
&__small-blue-text {
color: #037DD6;
}
&__info-row {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
&__data,
&__permission {
width: 100%;
}
&__permission {
.flex-row {
margin-top: 14px;
}
}
&__data {
&__data-block {
overflow-wrap: break-word;
margin-right: 16px;
margin-top: 12px;
}
}
&__footer {
display: flex;
align-items: flex-end;
margin-top: 16px;
padding-left: 34px;
padding-right: 24px;
.confirm-approve-content__small-text {
margin-left: 16px;
}
}
}
.confirm-approve-content--full {
height: auto;
}

View File

@ -1,20 +1,109 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import ConfirmTokenTransactionBase from '../confirm-token-transaction-base'
import ConfirmTransactionBase from '../confirm-transaction-base'
import ConfirmApproveContent from './confirm-approve-content'
import { getCustomTxParamsData } from './confirm-approve.util'
import {
calcTokenAmount,
} from '../../helpers/utils/token-util'
export default class ConfirmApprove extends Component {
static contextTypes = {
t: PropTypes.func,
}
static propTypes = {
tokenAddress: PropTypes.string,
toAddress: PropTypes.string,
tokenAmount: PropTypes.number,
tokenSymbol: PropTypes.string,
fiatTransactionTotal: PropTypes.string,
ethTransactionTotal: PropTypes.string,
contractExchangeRate: PropTypes.number,
conversionRate: PropTypes.number,
currentCurrency: PropTypes.string,
showCustomizeGasModal: PropTypes.func,
showEditApprovalPermissionModal: PropTypes.func,
origin: PropTypes.string,
siteImage: PropTypes.string,
tokenTrackerBalance: PropTypes.string,
data: PropTypes.string,
decimals: PropTypes.number,
txData: PropTypes.object,
}
static defaultProps = {
tokenAmount: 0,
}
state = {
customPermissionAmount: '',
}
componentDidUpdate (prevProps) {
const { tokenAmount } = this.props
if (tokenAmount !== prevProps.tokenAmount) {
this.setState({ customPermissionAmount: tokenAmount })
}
}
render () {
const { tokenAmount, tokenSymbol } = this.props
const {
toAddress,
tokenAddress,
tokenSymbol,
tokenAmount,
showCustomizeGasModal,
showEditApprovalPermissionModal,
origin,
siteImage,
tokenTrackerBalance,
data,
decimals,
txData,
ethTransactionTotal,
fiatTransactionTotal,
...restProps
} = this.props
const { customPermissionAmount } = this.state
const tokensText = `${tokenAmount} ${tokenSymbol}`
const tokenBalance = tokenTrackerBalance
? Number(calcTokenAmount(tokenTrackerBalance, decimals)).toPrecision(9)
: ''
return (
<ConfirmTokenTransactionBase
tokenAmount={tokenAmount}
warning={`By approving this action, you grant permission for this contract to spend up to ${tokenAmount} of your ${tokenSymbol}.`}
<ConfirmTransactionBase
toAddress={toAddress}
identiconAddress={tokenAddress}
showAccountInHeader={true}
title={tokensText}
contentComponent={<ConfirmApproveContent
siteImage={siteImage}
tokenAddress={tokenAddress}
setCustomAmount={(newAmount) => {
this.setState({ customPermissionAmount: newAmount })
}}
customTokenAmount={String(customPermissionAmount)}
tokenAmount={String(tokenAmount)}
origin={origin}
tokenSymbol={tokenSymbol}
tokenBalance={tokenBalance}
showCustomizeGasModal={() => showCustomizeGasModal(txData)}
showEditApprovalPermissionModal={showEditApprovalPermissionModal}
data={data}
toAddress={toAddress}
ethTransactionTotal={ethTransactionTotal}
fiatTransactionTotal={fiatTransactionTotal}
/>}
hideSenderToRecipient={true}
customTxParamsData={customPermissionAmount
? getCustomTxParamsData(data, { customPermissionAmount, tokenAmount, decimals })
: null
}
{...restProps}
/>
)
}

View File

@ -1,15 +1,102 @@
import { connect } from 'react-redux'
import { compose } from 'recompose'
import { withRouter } from 'react-router-dom'
import {
contractExchangeRateSelector,
transactionFeeSelector,
} from '../../selectors/confirm-transaction'
import { showModal } from '../../store/actions'
import { tokenSelector } from '../../selectors/tokens'
import {
getTokenData,
} from '../../helpers/utils/transactions.util'
import withTokenTracker from '../../helpers/higher-order-components/with-token-tracker'
import {
calcTokenAmount,
getTokenToAddress,
getTokenValue,
} from '../../helpers/utils/token-util'
import ConfirmApprove from './confirm-approve.component'
import { approveTokenAmountAndToAddressSelector } from '../../selectors/confirm-transaction'
const mapStateToProps = state => {
const { confirmTransaction: { tokenProps: { tokenSymbol } = {} } } = state
const { tokenAmount } = approveTokenAmountAndToAddressSelector(state)
const mapStateToProps = (state, ownProps) => {
const { match: { params = {} } } = ownProps
const { id: paramsTransactionId } = params
const {
confirmTransaction,
metamask: { currentCurrency, conversionRate, selectedAddressTxList, approvedOrigins, selectedAddress },
} = state
const {
txData: { id: transactionId, txParams: { to: tokenAddress, data } = {} } = {},
} = confirmTransaction
const transaction = selectedAddressTxList.find(({ id }) => id === (Number(paramsTransactionId) || transactionId)) || {}
const {
ethTransactionTotal,
fiatTransactionTotal,
} = transactionFeeSelector(state, transaction)
const tokens = tokenSelector(state)
const currentToken = tokens && tokens.find(({ address }) => tokenAddress === address)
const { decimals, symbol: tokenSymbol } = currentToken || {}
const tokenData = getTokenData(data)
const tokenValue = tokenData && getTokenValue(tokenData.params)
const toAddress = tokenData && getTokenToAddress(tokenData.params)
const tokenAmount = tokenData && calcTokenAmount(tokenValue, decimals).toNumber()
const contractExchangeRate = contractExchangeRateSelector(state)
const { origin } = transaction
const formattedOrigin = origin
? origin[0].toUpperCase() + origin.slice(1)
: ''
const { siteImage } = approvedOrigins[origin] || {}
return {
toAddress,
tokenAddress,
tokenAmount,
currentCurrency,
conversionRate,
contractExchangeRate,
fiatTransactionTotal,
ethTransactionTotal,
tokenSymbol,
siteImage,
token: { address: tokenAddress },
userAddress: selectedAddress,
origin: formattedOrigin,
data,
decimals: Number(decimals),
txData: transaction,
}
}
export default connect(mapStateToProps)(ConfirmApprove)
const mapDispatchToProps = (dispatch) => {
return {
showCustomizeGasModal: (txData) => dispatch(showModal({ name: 'CUSTOMIZE_GAS', txData })),
showEditApprovalPermissionModal: ({
tokenAmount,
customTokenAmount,
tokenSymbol,
tokenBalance,
setCustomAmount,
origin,
}) => dispatch(showModal({
name: 'EDIT_APPROVAL_PERMISSION',
tokenAmount,
customTokenAmount,
tokenSymbol,
tokenBalance,
setCustomAmount,
origin,
})),
}
}
export default compose(
withRouter,
connect(mapStateToProps, mapDispatchToProps),
withTokenTracker,
)(ConfirmApprove)

View File

@ -0,0 +1,28 @@
import { decimalToHex } from '../../helpers/utils/conversions.util'
import { calcTokenValue } from '../../helpers/utils/token-util.js'
export function getCustomTxParamsData (data, { customPermissionAmount, tokenAmount, decimals }) {
if (customPermissionAmount) {
const tokenValue = decimalToHex(calcTokenValue(tokenAmount, decimals))
const re = new RegExp('(^.+)' + tokenValue + '$')
const matches = re.exec(data)
if (!matches || !matches[1]) {
return data
}
let dataWithoutCurrentAmount = matches[1]
const customPermissionValue = decimalToHex(calcTokenValue(Number(customPermissionAmount), decimals))
const differenceInLengths = customPermissionValue.length - tokenValue.length
const zeroModifier = dataWithoutCurrentAmount.length - differenceInLengths
if (differenceInLengths > 0) {
dataWithoutCurrentAmount = dataWithoutCurrentAmount.slice(0, zeroModifier)
} else if (differenceInLengths < 0) {
dataWithoutCurrentAmount = dataWithoutCurrentAmount.padEnd(zeroModifier, 0)
}
const customTxParamsData = dataWithoutCurrentAmount + customPermissionValue
return customTxParamsData
}
}

View File

@ -0,0 +1 @@
@import 'confirm-approve-content/index';

View File

@ -105,6 +105,8 @@ export default class ConfirmTransactionBase extends Component {
getNextNonce: PropTypes.func,
nextNonce: PropTypes.number,
tryReverseResolveAddress: PropTypes.func.isRequired,
hideSenderToRecipient: PropTypes.bool,
showAccountInHeader: PropTypes.bool,
}
state = {
@ -645,6 +647,8 @@ export default class ConfirmTransactionBase extends Component {
warning,
unapprovedTxCount,
transactionCategory,
hideSenderToRecipient,
showAccountInHeader,
} = this.props
const { submitting, submitError, submitWarning } = this.state
@ -655,6 +659,7 @@ export default class ConfirmTransactionBase extends Component {
<ConfirmPageContainer
fromName={fromName}
fromAddress={fromAddress}
showAccountInHeader={showAccountInHeader}
toName={toName}
toAddress={toAddress}
toEns={toEns}
@ -693,6 +698,7 @@ export default class ConfirmTransactionBase extends Component {
onCancelAll={() => this.handleCancelAll()}
onCancel={() => this.handleCancel()}
onSubmit={() => this.handleSubmit()}
hideSenderToRecipient={hideSenderToRecipient}
/>
)
}

View File

@ -46,7 +46,7 @@ const customNonceMerge = txData => customNonceValue ? ({
}) : txData
const mapStateToProps = (state, ownProps) => {
const { toAddress: propsToAddress, match: { params = {} } } = ownProps
const { toAddress: propsToAddress, customTxParamsData, match: { params = {} } } = ownProps
const { id: paramsTransactionId } = params
const { showFiatInTestnets } = preferencesSelector(state)
const isMainnet = getIsMainnet(state)
@ -133,6 +133,17 @@ const mapStateToProps = (state, ownProps) => {
const methodData = getKnownMethodData(state, data) || {}
let fullTxData = { ...txData, ...transaction }
if (customTxParamsData) {
fullTxData = {
...fullTxData,
txParams: {
...fullTxData.txParams,
data: customTxParamsData,
},
}
}
return {
balance,
fromAddress,
@ -150,7 +161,7 @@ const mapStateToProps = (state, ownProps) => {
hexTransactionAmount,
hexTransactionFee,
hexTransactionTotal,
txData: { ...txData, ...transaction },
txData: fullTxData,
tokenData,
methodData,
tokenProps,

View File

@ -11,3 +11,5 @@
@import 'first-time-flow/index';
@import 'keychains/index';
@import 'confirm-approve/index';