From f7a19e06dc7ad84a1d6e0abf587e3525421fc4f7 Mon Sep 17 00:00:00 2001 From: Dan Miller Date: Mon, 7 Oct 2019 20:27:23 -0230 Subject: [PATCH] Redesign approve screen --- app/images/user-check.svg | 3 + test/e2e/metamask-ui.spec.js | 69 ++-- .../confirm-page-container-content/index.scss | 1 + .../confirm-page-container.component.js | 21 +- .../app/confirm-page-container/index.scss | 6 + .../components/app/modal/modal.component.js | 9 +- .../edit-approval-permission.component.js | 161 +++++++++ .../edit-approval-permission.container.js | 26 ++ .../modals/edit-approval-permission/index.js | 1 + .../edit-approval-permission/index.scss | 167 ++++++++++ ui/app/components/app/modals/index.scss | 2 + ui/app/components/app/modals/modal.js | 26 ++ ui/app/css/itcss/tools/utilities.scss | 4 +- .../with-token-tracker.component.js | 9 +- ui/app/helpers/utils/token-util.js | 5 + .../confirm-approve-content.component.js | 218 +++++++++++++ .../confirm-approve-content/index.js | 1 + .../confirm-approve-content/index.scss | 306 ++++++++++++++++++ .../confirm-approve.component.js | 98 +++++- .../confirm-approve.container.js | 95 +++++- .../confirm-approve/confirm-approve.util.js | 28 ++ ui/app/pages/confirm-approve/index.scss | 1 + .../confirm-transaction-base.component.js | 3 + .../confirm-transaction-base.container.js | 15 +- ui/app/pages/index.scss | 2 + 25 files changed, 1222 insertions(+), 55 deletions(-) create mode 100644 app/images/user-check.svg create mode 100644 ui/app/components/app/modals/edit-approval-permission/edit-approval-permission.component.js create mode 100644 ui/app/components/app/modals/edit-approval-permission/edit-approval-permission.container.js create mode 100644 ui/app/components/app/modals/edit-approval-permission/index.js create mode 100644 ui/app/components/app/modals/edit-approval-permission/index.scss create mode 100644 ui/app/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js create mode 100644 ui/app/pages/confirm-approve/confirm-approve-content/index.js create mode 100644 ui/app/pages/confirm-approve/confirm-approve-content/index.scss create mode 100644 ui/app/pages/confirm-approve/confirm-approve.util.js create mode 100644 ui/app/pages/confirm-approve/index.scss diff --git a/app/images/user-check.svg b/app/images/user-check.svg new file mode 100644 index 000000000000..8ba739338e3d --- /dev/null +++ b/app/images/user-check.svg @@ -0,0 +1,3 @@ + + + diff --git a/test/e2e/metamask-ui.spec.js b/test/e2e/metamask-ui.spec.js index 664cc4b6bed3..16e9a618e657 100644 --- a/test/e2e/metamask-ui.spec.js +++ b/test/e2e/metamask-ui.spec.js @@ -1188,8 +1188,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() if (process.env.SELENIUM_BROWSER !== 'firefox') { await closeAllWindowHandlesExcept(driver, [extension, dapp]) @@ -1210,31 +1210,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')) @@ -1276,14 +1267,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 permissionModal = await driver.findElement(By.css('span .modal')) + + const editButtons = await findElements(driver, By.css('.confirm-approve-content__small-blue-text.cursor-pointer')) + await editButtons[1].click() + await delay(regularDelayMs) + + 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 DAI') }) it('submits the transaction', async function () { @@ -1401,9 +1412,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 () { diff --git a/ui/app/components/app/confirm-page-container/confirm-page-container-content/index.scss b/ui/app/components/app/confirm-page-container/confirm-page-container-content/index.scss index 602a468488c6..ebc252e73557 100644 --- a/ui/app/components/app/confirm-page-container/confirm-page-container-content/index.scss +++ b/ui/app/components/app/confirm-page-container/confirm-page-container-content/index.scss @@ -4,6 +4,7 @@ .confirm-page-container-content { overflow-y: auto; + height: 100%; flex: 1; &__error-container { diff --git a/ui/app/components/app/confirm-page-container/confirm-page-container.component.js b/ui/app/components/app/confirm-page-container/confirm-page-container.component.js index d26daf78694c..65487e90bd29 100644 --- a/ui/app/components/app/confirm-page-container/confirm-page-container.component.js +++ b/ui/app/components/app/confirm-page-container/confirm-page-container.component.js @@ -19,6 +19,7 @@ export default class ConfirmPageContainer extends Component { subtitleComponent: PropTypes.node, title: PropTypes.string, titleComponent: PropTypes.node, + hideSenderToRecipient: PropTypes.bool, // Sender to Recipient fromAddress: PropTypes.string, fromName: PropTypes.string, @@ -102,6 +103,7 @@ export default class ConfirmPageContainer extends Component { lastTx, ofText, requestsWaitingText, + hideSenderToRecipient, } = this.props const renderAssetImage = contentComponent || (!contentComponent && !identiconAddress) @@ -123,14 +125,17 @@ export default class ConfirmPageContainer extends Component { showEdit={showEdit} onEdit={() => onEdit()} > - + { hideSenderToRecipient + ? null + : + } { contentComponent || ( diff --git a/ui/app/components/app/confirm-page-container/index.scss b/ui/app/components/app/confirm-page-container/index.scss index c0277eff57e5..3fc72c3a6d87 100644 --- a/ui/app/components/app/confirm-page-container/index.scss +++ b/ui/app/components/app/confirm-page-container/index.scss @@ -5,3 +5,9 @@ @import 'confirm-detail-row/index'; @import 'confirm-page-container-navigation/index'; + +.page-container { + &__content-component-wrapper { + height: 100%; + } +} diff --git a/ui/app/components/app/modal/modal.component.js b/ui/app/components/app/modal/modal.component.js index 44b180ac8361..f0fdd3bd522a 100644 --- a/ui/app/components/app/modal/modal.component.js +++ b/ui/app/components/app/modal/modal.component.js @@ -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 ( -
+
{ headerText && (
@@ -53,7 +58,7 @@ export default class Modal extends PureComponent {
) } -
+
{ children }
diff --git a/ui/app/components/app/modals/edit-approval-permission/edit-approval-permission.component.js b/ui/app/components/app/modals/edit-approval-permission/edit-approval-permission.component.js new file mode 100644 index 000000000000..22651099f795 --- /dev/null +++ b/ui/app/components/app/modals/edit-approval-permission/edit-approval-permission.component.js @@ -0,0 +1,161 @@ +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, + } + + static contextTypes = { + t: PropTypes.func, + } + + state = { + customSpendLimit: this.props.customTokenAmount, + selectedOption: this.props.customTokenAmount ? 'custom' : 'unlimited', + } + + renderModalContent () { + const { + hideModal, + selectedIdentity, + tokenAmount, + tokenSymbol, + tokenBalance, + customTokenAmount, + } = this.props + const { name, address } = selectedIdentity || {} + + return ( +
+
+
+ Edit Permission +
+
hideModal()} + /> +
+
+
+ +
{ name }
+
Balance
+
+
+ {`${tokenBalance} ${tokenSymbol}`} +
+
+
+
+ Spend limit permission +
+
+ Allow Uniswap to withdraw and spend up to the following amount: +
+
+
this.setState({ selectedOption: 'unlimited' })} + > +
+
+ {this.state.selectedOption === 'unlimited' &&
} +
+
+
+ { + tokenAmount < tokenBalance + ? -'Proposed Approval Limit' + : 'Unlimited' + } +
+
+ Spend limit requested by Uniswap +
+
+ {`${tokenAmount} ${tokenSymbol}`} +
+
+
+
+
this.setState({ selectedOption: 'custom' })} + > +
+
+ {this.state.selectedOption === 'custom' &&
} +
+
+
+ Custom spend limit +
+
+ Enter a max spend limit +
+
+ this.setState({ customSpendLimit: event.target.value })} + fullWidth + margin="dense" + value={ this.state.customSpendLimit } + /> +
+
+
+
+
+ ) + } + + render () { + const { t } = this.context + const { setCustomAmount, hideModal } = this.props + + return ( + { + setCustomAmount(Number(this.state.customSpendLimit)) + hideModal() + }} + submitText={t('save')} + submitType="primary" + contentClass="edit-approval-permission-modal-content" + containerClass="edit-approval-permission-modal-container" + submitDisabled={ this.state.customSpendLimit === this.props.customTokenAmount } + > + { this.renderModalContent() } + + ) + } +} diff --git a/ui/app/components/app/modals/edit-approval-permission/edit-approval-permission.container.js b/ui/app/components/app/modals/edit-approval-permission/edit-approval-permission.container.js new file mode 100644 index 000000000000..6a53b5f67161 --- /dev/null +++ b/ui/app/components/app/modals/edit-approval-permission/edit-approval-permission.container.js @@ -0,0 +1,26 @@ +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 { resetAccount } from '../../../../store/actions' +import { getSelectedIdentity } from '../../../../selectors/selectors' + +const mapStateToProps = (state) => { + const modalStateProps = state.appState.modal.modalState.props || {} + return { + selectedIdentity: getSelectedIdentity(state), + ...modalStateProps, + } +} + + +const mapDispatchToProps = dispatch => { + return { + resetAccount: () => dispatch(resetAccount()), + } +} + +export default compose( + withModalProps, + connect(mapStateToProps, mapDispatchToProps) +)(EditApprovalPermission) diff --git a/ui/app/components/app/modals/edit-approval-permission/index.js b/ui/app/components/app/modals/edit-approval-permission/index.js new file mode 100644 index 000000000000..3f50d3e9991e --- /dev/null +++ b/ui/app/components/app/modals/edit-approval-permission/index.js @@ -0,0 +1 @@ +export { default } from './edit-approval-permission.container' diff --git a/ui/app/components/app/modals/edit-approval-permission/index.scss b/ui/app/components/app/modals/edit-approval-permission/index.scss new file mode 100644 index 000000000000..f400da4c1cf4 --- /dev/null +++ b/ui/app/components/app/modals/edit-approval-permission/index.scss @@ -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%; +} diff --git a/ui/app/components/app/modals/index.scss b/ui/app/components/app/modals/index.scss index d93a41140a73..da7a27b84086 100644 --- a/ui/app/components/app/modals/index.scss +++ b/ui/app/components/app/modals/index.scss @@ -9,3 +9,5 @@ @import 'metametrics-opt-in-modal/index'; @import './add-to-addressbook-modal/index'; + +@import './edit-approval-permission/index'; diff --git a/ui/app/components/app/modals/modal.js b/ui/app/components/app/modals/modal.js index c901d6db8137..ada758b9913d 100644 --- a/ui/app/components/app/modals/modal.js +++ b/ui/app/components/app/modals/modal.js @@ -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), diff --git a/ui/app/css/itcss/tools/utilities.scss b/ui/app/css/itcss/tools/utilities.scss index 209614c6bc78..81eb18d06ca4 100644 --- a/ui/app/css/itcss/tools/utilities.scss +++ b/ui/app/css/itcss/tools/utilities.scss @@ -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 { diff --git a/ui/app/helpers/higher-order-components/with-token-tracker/with-token-tracker.component.js b/ui/app/helpers/higher-order-components/with-token-tracker/with-token-tracker.component.js index 36f6a6efdd0e..8025dd5bc4c0 100644 --- a/ui/app/helpers/higher-order-components/with-token-tracker/with-token-tracker.component.js +++ b/ui/app/helpers/higher-order-components/with-token-tracker/with-token-tracker.component.js @@ -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 ( ) diff --git a/ui/app/helpers/utils/token-util.js b/ui/app/helpers/utils/token-util.js index 831d851316aa..2c4f67fd0b9d 100644 --- a/ui/app/helpers/utils/token-util.js +++ b/ui/app/helpers/utils/token-util.js @@ -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 diff --git a/ui/app/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js b/ui/app/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js new file mode 100644 index 000000000000..c65e009e3dc6 --- /dev/null +++ b/ui/app/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js @@ -0,0 +1,218 @@ +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 ( +
+
+
{ symbol }
+
{ title }
+ { showEdit &&
onEditClick()} + >Edit
} +
+
+ { content } +
+ { footer } +
+ ) + } + + renderTransactionDetailsContent () { + const { + ethTransactionTotal, + fiatTransactionTotal, + } = this.props + return ( +
+
+ A fee is associated with this request. Learn why +
+
+
+ { fiatTransactionTotal } +
+
+ { ethTransactionTotal } +
+
+
+ ) + } + + renderPermissionContent () { + const { customTokenAmount, tokenAmount, tokenSymbol, origin, toAddress } = this.props + + return ( +
+
{origin} may access and spend up to this max amount
+
+
Amount:
+
{ `${customTokenAmount || tokenAmount} ${tokenSymbol}` }
+
+
+
To:
+
{ addressSummary(toAddress) }
+
+
+ ) + } + + renderDataContent () { + const { data } = this.props + return ( +
+
Function: Approve
+
{ data }
+
+ ) + } + + render () { + const { + siteImage, + tokenAmount, + customTokenAmount, + origin, + tokenSymbol, + showCustomizeGasModal, + showEditApprovalPermissionModal, + setCustomAmount, + tokenBalance, + } = this.props + const { showFullTxDetails } = this.state + + return ( +
+
+ +
+
+ { `Allow ${origin} to spend your ${tokenSymbol}?` } +
+
+ { `Do you trust this site? By granting this permission, you’re allowing ${origin} to withdraw your ${tokenSymbol} and automate transactions for you.` } +
+
+
showEditApprovalPermissionModal({ customTokenAmount, tokenAmount, tokenSymbol, setCustomAmount, tokenBalance })} + > + Edit permission +
+
+
+ {this.renderApproveContentCard({ + symbol: , + title: 'Transaction Fee', + showEdit: true, + onEditClick: showCustomizeGasModal, + content: this.renderTransactionDetailsContent(), + noBorder: !showFullTxDetails, + footer:
this.setState({ showFullTxDetails: !this.state.showFullTxDetails })} + > +
+
+ View full transaction details +
+ +
+
, + })} +
+ + { + showFullTxDetails + ? ( +
+
+ {this.renderApproveContentCard({ + symbol: , + title: 'Permission', + content: this.renderPermissionContent(), + showEdit: true, + onEditClick: () => showEditApprovalPermissionModal({ + customTokenAmount, + tokenAmount, + tokenSymbol, + tokenBalance, + setCustomAmount, + }), + })} +
+
+ {this.renderApproveContentCard({ + symbol: , + title: 'Data', + content: this.renderDataContent(), + noBorder: true, + })} +
+
+ ) + : null + } +
+ ) + } +} diff --git a/ui/app/pages/confirm-approve/confirm-approve-content/index.js b/ui/app/pages/confirm-approve/confirm-approve-content/index.js new file mode 100644 index 000000000000..8f225387a260 --- /dev/null +++ b/ui/app/pages/confirm-approve/confirm-approve-content/index.js @@ -0,0 +1 @@ +export { default } from './confirm-approve-content.component' diff --git a/ui/app/pages/confirm-approve/confirm-approve-content/index.scss b/ui/app/pages/confirm-approve/confirm-approve-content/index.scss new file mode 100644 index 000000000000..512d19af64d4 --- /dev/null +++ b/ui/app/pages/confirm-approve/confirm-approve-content/index.scss @@ -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: 24px; + 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: 24px; + 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: 23px; + padding-bottom: 40px; + 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; +} diff --git a/ui/app/pages/confirm-approve/confirm-approve.component.js b/ui/app/pages/confirm-approve/confirm-approve.component.js index b71eaa1d4804..20595fe3fd93 100644 --- a/ui/app/pages/confirm-approve/confirm-approve.component.js +++ b/ui/app/pages/confirm-approve/confirm-approve.component.js @@ -1,20 +1,108 @@ 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 ( - { + 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} /> ) } diff --git a/ui/app/pages/confirm-approve/confirm-approve.container.js b/ui/app/pages/confirm-approve/confirm-approve.container.js index 5f8bb8f0b3ea..8f50d99ebd1a 100644 --- a/ui/app/pages/confirm-approve/confirm-approve.container.js +++ b/ui/app/pages/confirm-approve/confirm-approve.container.js @@ -1,15 +1,100 @@ 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, + 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, + }) => dispatch(showModal({ + name: 'EDIT_APPROVAL_PERMISSION', + tokenAmount, + customTokenAmount, + tokenSymbol, + tokenBalance, + setCustomAmount, + })), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps), + withTokenTracker, +)(ConfirmApprove) + diff --git a/ui/app/pages/confirm-approve/confirm-approve.util.js b/ui/app/pages/confirm-approve/confirm-approve.util.js new file mode 100644 index 000000000000..6e09523a1637 --- /dev/null +++ b/ui/app/pages/confirm-approve/confirm-approve.util.js @@ -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(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 + } +} diff --git a/ui/app/pages/confirm-approve/index.scss b/ui/app/pages/confirm-approve/index.scss new file mode 100644 index 000000000000..18d7c29e82e7 --- /dev/null +++ b/ui/app/pages/confirm-approve/index.scss @@ -0,0 +1 @@ +@import 'confirm-approve-content/index'; diff --git a/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js index 5ad1ff7d306c..7764bdfd9b04 100644 --- a/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js +++ b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js @@ -102,6 +102,7 @@ export default class ConfirmTransactionBase extends Component { transactionCategory: PropTypes.string, getNextNonce: PropTypes.func, nextNonce: PropTypes.number, + hideSenderToRecipient: PropTypes.bool, } state = { @@ -623,6 +624,7 @@ export default class ConfirmTransactionBase extends Component { warning, unapprovedTxCount, transactionCategory, + hideSenderToRecipient, } = this.props const { submitting, submitError, submitWarning } = this.state @@ -670,6 +672,7 @@ export default class ConfirmTransactionBase extends Component { onCancelAll={() => this.handleCancelAll()} onCancel={() => this.handleCancel()} onSubmit={() => this.handleSubmit()} + hideSenderToRecipient={hideSenderToRecipient} /> ) } diff --git a/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js index 6ffa13bd0dd8..bca38669507b 100644 --- a/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js +++ b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -45,7 +45,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) @@ -129,6 +129,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, @@ -145,7 +156,7 @@ const mapStateToProps = (state, ownProps) => { hexTransactionAmount, hexTransactionFee, hexTransactionTotal, - txData: { ...txData, ...transaction }, + txData: fullTxData, tokenData, methodData, tokenProps, diff --git a/ui/app/pages/index.scss b/ui/app/pages/index.scss index e7242392b8a5..d79b7c28ddff 100644 --- a/ui/app/pages/index.scss +++ b/ui/app/pages/index.scss @@ -11,3 +11,5 @@ @import 'first-time-flow/index'; @import 'keychains/index'; + +@import 'confirm-approve/index';