diff options
author | Alexander Tseung <alextsg@users.noreply.github.com> | 2018-09-13 11:07:59 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-09-13 11:07:59 +0800 |
commit | 16d6cd5eb90e9720799f9a69e9c4e7d66d2fa89b (patch) | |
tree | f497f9f7d991b290f48cf7e5120a2f2e95d8f81f | |
parent | 014240b066585f5983a4e6d65d6223f235044380 (diff) | |
parent | f1a309e0cc110060cc56252ec5f7626ca6403fab (diff) | |
download | tangerine-wallet-browser-16d6cd5eb90e9720799f9a69e9c4e7d66d2fa89b.tar tangerine-wallet-browser-16d6cd5eb90e9720799f9a69e9c4e7d66d2fa89b.tar.gz tangerine-wallet-browser-16d6cd5eb90e9720799f9a69e9c4e7d66d2fa89b.tar.bz2 tangerine-wallet-browser-16d6cd5eb90e9720799f9a69e9c4e7d66d2fa89b.tar.lz tangerine-wallet-browser-16d6cd5eb90e9720799f9a69e9c4e7d66d2fa89b.tar.xz tangerine-wallet-browser-16d6cd5eb90e9720799f9a69e9c4e7d66d2fa89b.tar.zst tangerine-wallet-browser-16d6cd5eb90e9720799f9a69e9c4e7d66d2fa89b.zip |
Merge pull request #5182 from MetaMask/tx-activity
Add Transaction Details to the Transaction List view
69 files changed, 1449 insertions, 328 deletions
diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 14e867b33..2cfd15f50 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -17,6 +17,9 @@ "accountSelectionRequired": { "message": "You need to select an account!" }, + "activityLog": { + "message": "activity log" + }, "address": { "message": "Address" }, @@ -857,6 +860,9 @@ "save": { "message": "Save" }, + "speedUp": { + "message": "speed up" + }, "speedUpTitle": { "message": "Speed Up Transaction" }, @@ -1085,6 +1091,27 @@ "total": { "message": "Total" }, + "transaction": { + "message": "transaction" + }, + "transactionConfirmed": { + "message": "Transaction confirmed on $2." + }, + "transactionCreated": { + "message": "Transaction created with a value of $1 on $2." + }, + "transactionDropped": { + "message": "Transaction dropped on $2." + }, + "transactionSubmitted": { + "message": "Transaction submitted on $2." + }, + "transactionUpdated": { + "message": "Transaction updated on $2." + }, + "transactionUpdatedGas": { + "message": "Transaction updated with a gas price of $1 on $2." + }, "transactions": { "message": "transactions" }, @@ -1131,6 +1158,9 @@ "unavailable": { "message": "Unavailable" }, + "units": { + "message": "units" + }, "unknown": { "message": "Unknown" }, diff --git a/app/images/arrow-popout.svg b/app/images/arrow-popout.svg new file mode 100644 index 000000000..7e25f7cd2 --- /dev/null +++ b/app/images/arrow-popout.svg @@ -0,0 +1,3 @@ +<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M8.67589 0.641872C8.65169 0.642635 8.62756 0.644749 8.6036 0.648202H4.79279C4.55863 0.644896 4.34082 0.767704 4.22278 0.969601C4.10473 1.1715 4.10473 1.4212 4.22278 1.6231C4.34082 1.825 4.55863 1.9478 4.79279 1.9445H7.12113L0.437932 8.61587C0.268309 8.77843 0.19998 9.01984 0.259298 9.24697C0.318616 9.47411 0.496311 9.65149 0.723852 9.71071C0.951393 9.76992 1.19322 9.70171 1.35608 9.53239L8.03927 2.86102V5.18524C8.03596 5.41898 8.15899 5.6364 8.36124 5.75424C8.56349 5.87208 8.81364 5.87208 9.0159 5.75424C9.21815 5.6364 9.34118 5.41898 9.33787 5.18524V1.37863C9.36404 1.18976 9.30558 0.998955 9.17804 0.857009C9.0505 0.715062 8.86682 0.636369 8.67589 0.641872Z" fill="#359BDD"/> +</svg> diff --git a/test/e2e/beta/from-import-beta-ui.spec.js b/test/e2e/beta/from-import-beta-ui.spec.js index f8a904263..32aaa29a6 100644 --- a/test/e2e/beta/from-import-beta-ui.spec.js +++ b/test/e2e/beta/from-import-beta-ui.spec.js @@ -317,7 +317,7 @@ describe('Using MetaMask with an existing account', 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--secondary')) + const txValues = await findElements(driver, By.css('.transaction-list-item__amount--primary')) assert.equal(txValues.length, 1) assert.equal(await txValues[0].getText(), '-1 ETH') }) diff --git a/test/e2e/beta/metamask-beta-ui.spec.js b/test/e2e/beta/metamask-beta-ui.spec.js index c9f759780..0864ef236 100644 --- a/test/e2e/beta/metamask-beta-ui.spec.js +++ b/test/e2e/beta/metamask-beta-ui.spec.js @@ -408,7 +408,7 @@ describe('MetaMask', function () { assert.equal(transactions.length, 1) if (process.env.SELENIUM_BROWSER !== 'firefox') { - const txValues = await findElement(driver, By.css('.transaction-list-item__amount--secondary')) + const txValues = await findElement(driver, By.css('.transaction-list-item__amount--primary')) await driver.wait(until.elementTextMatches(txValues, /-1\sETH/), 10000) } }) @@ -450,7 +450,7 @@ describe('MetaMask', function () { const transactions = await findElements(driver, By.css('.transaction-list-item')) assert.equal(transactions.length, 2) - const txValues = await findElement(driver, By.css('.transaction-list-item__amount--secondary')) + const txValues = await findElement(driver, By.css('.transaction-list-item__amount--primary')) await driver.wait(until.elementTextMatches(txValues, /-3\sETH/), 10000) }) }) @@ -528,7 +528,7 @@ describe('MetaMask', function () { await delay(largeDelayMs) await findElements(driver, By.css('.transaction-list-item')) - const [txListValue] = await findElements(driver, By.css('.transaction-list-item__amount--secondary')) + const [txListValue] = await findElements(driver, By.css('.transaction-list-item__amount--primary')) await driver.wait(until.elementTextMatches(txListValue, /-4\sETH/), 10000) await txListValue.click() await delay(regularDelayMs) @@ -562,7 +562,7 @@ describe('MetaMask', function () { return confirmedTxes.length === 4 }, 10000) - const txValues = await findElements(driver, By.css('.transaction-list-item__amount--secondary')) + const txValues = await findElements(driver, By.css('.transaction-list-item__amount--primary')) await driver.wait(until.elementTextMatches(txValues[0], /-4\sETH/), 10000) // const txAccounts = await findElements(driver, By.css('.tx-list-account')) @@ -594,7 +594,7 @@ describe('MetaMask', function () { return confirmedTxes.length === 5 }, 10000) - const txValues = await findElement(driver, By.css('.transaction-list-item__amount--secondary')) + const txValues = await findElement(driver, By.css('.transaction-list-item__amount--primary')) await driver.wait(until.elementTextMatches(txValues, /-0\sETH/), 10000) await closeAllWindowHandlesExcept(driver, [extension, dapp]) diff --git a/test/integration/lib/confirm-sig-requests.js b/test/integration/lib/confirm-sig-requests.js index 3539e97be..9c2ad7cf4 100644 --- a/test/integration/lib/confirm-sig-requests.js +++ b/test/integration/lib/confirm-sig-requests.js @@ -19,7 +19,7 @@ async function runConfirmSigRequestsTest (assert, done) { selectState.val('confirm sig requests') reactTriggerChange(selectState[0]) - const pendingRequestItem = $.find('.transaction-list-item') + const pendingRequestItem = $.find('.transaction-list-item .transaction-list-item__grid') if (pendingRequestItem[0]) { pendingRequestItem[0].click() diff --git a/test/integration/lib/tx-list-items.js b/test/integration/lib/tx-list-items.js index 7572d1629..8f291c7b4 100644 --- a/test/integration/lib/tx-list-items.js +++ b/test/integration/lib/tx-list-items.js @@ -32,9 +32,11 @@ async function runTxListItemsTest (assert, done) { const txListItems = await queryAsync($, '.transaction-list-item') assert.equal(txListItems.length, 8, 'all tx list items are rendered') - const retryTx = txListItems[1] - const retryTxLink = await findAsync($(retryTx), '.transaction-list-item__retry') - assert.equal(retryTxLink[0].textContent, 'Taking too long? Increase the gas price on your transaction', 'retryTx has expected link') + const retryTxGrid = await findAsync($(txListItems[1]), '.transaction-list-item__grid') + retryTxGrid[0].click() + const retryTxDetails = await findAsync($(txListItems[1]), '.transaction-list-item-details') + const headerButtons = await findAsync($(retryTxDetails[0]), '.transaction-list-item-details__header-button') + assert.equal(headerButtons[0].textContent, 'speed up') const approvedTx = txListItems[2] const approvedTxRenderedStatus = await findAsync($(approvedTx), '.transaction-list-item__status') diff --git a/ui/app/components/button/button.component.js b/ui/app/components/button/button.component.js index 1e0ef1b64..4a06333e7 100644 --- a/ui/app/components/button/button.component.js +++ b/ui/app/components/button/button.component.js @@ -6,6 +6,7 @@ const CLASSNAME_DEFAULT = 'btn-default' const CLASSNAME_PRIMARY = 'btn-primary' const CLASSNAME_SECONDARY = 'btn-secondary' const CLASSNAME_CONFIRM = 'btn-confirm' +const CLASSNAME_RAISED = 'btn-raised' const CLASSNAME_LARGE = 'btn--large' const typeHash = { @@ -13,6 +14,7 @@ const typeHash = { primary: CLASSNAME_PRIMARY, secondary: CLASSNAME_SECONDARY, confirm: CLASSNAME_CONFIRM, + raised: CLASSNAME_RAISED, } export default class Button extends Component { @@ -20,7 +22,7 @@ export default class Button extends Component { type: PropTypes.string, large: PropTypes.bool, className: PropTypes.string, - children: PropTypes.string, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), } render () { @@ -29,6 +31,7 @@ export default class Button extends Component { return ( <button className={classnames( + 'button', typeHash[type], large && CLASSNAME_LARGE, className diff --git a/ui/app/components/card/card.component.js b/ui/app/components/card/card.component.js new file mode 100644 index 000000000..bb7241da1 --- /dev/null +++ b/ui/app/components/card/card.component.js @@ -0,0 +1,25 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +export default class Card extends PureComponent { + static propTypes = { + className: PropTypes.string, + overrideClassName: PropTypes.bool, + title: PropTypes.string, + children: PropTypes.node, + } + + render () { + const { className, overrideClassName, title } = this.props + + return ( + <div className={classnames({ 'card': !overrideClassName }, className)}> + <div className="card__title"> + { title } + </div> + { this.props.children } + </div> + ) + } +} diff --git a/ui/app/components/card/index.js b/ui/app/components/card/index.js new file mode 100644 index 000000000..c3ca6e3f4 --- /dev/null +++ b/ui/app/components/card/index.js @@ -0,0 +1 @@ +export { default } from './card.component' diff --git a/ui/app/components/card/index.scss b/ui/app/components/card/index.scss new file mode 100644 index 000000000..bde54a15e --- /dev/null +++ b/ui/app/components/card/index.scss @@ -0,0 +1,11 @@ +.card { + border-radius: 4px; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.08); + padding: 8px; + + &__title { + border-bottom: 1px solid #d8d8d8; + padding-bottom: 4px; + text-transform: capitalize; + } +} diff --git a/ui/app/components/card/tests/card.component.test.js b/ui/app/components/card/tests/card.component.test.js new file mode 100644 index 000000000..cea05033f --- /dev/null +++ b/ui/app/components/card/tests/card.component.test.js @@ -0,0 +1,25 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import Card from '../card.component' + +describe('Card Component', () => { + it('should render a card with a title and child element', () => { + const wrapper = shallow( + <Card + title="Test" + className="card-test-class" + > + <div className="child-test-class">Child</div> + </Card> + ) + + assert.ok(wrapper.hasClass('card-test-class')) + const title = wrapper.find('.card__title') + assert.ok(title) + assert.equal(title.text(), 'Test') + const child = wrapper.find('.child-test-class') + assert.ok(child) + assert.equal(child.text(), 'Child') + }) +}) diff --git a/ui/app/components/currency-display/currency-display.component.js b/ui/app/components/currency-display/currency-display.component.js index 389791b42..e4eb58a2a 100644 --- a/ui/app/components/currency-display/currency-display.component.js +++ b/ui/app/components/currency-display/currency-display.component.js @@ -1,13 +1,18 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' -import { ETH } from '../../constants/common' +import { ETH, GWEI } from '../../constants/common' export default class CurrencyDisplay extends PureComponent { static propTypes = { className: PropTypes.string, displayValue: PropTypes.string, prefix: PropTypes.string, + // Used in container currency: PropTypes.oneOf([ETH]), + denomination: PropTypes.oneOf([GWEI]), + value: PropTypes.string, + numberOfDecimals: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + hideLabel: PropTypes.bool, } render () { diff --git a/ui/app/components/currency-display/currency-display.container.js b/ui/app/components/currency-display/currency-display.container.js index b8a738c65..6644a1099 100644 --- a/ui/app/components/currency-display/currency-display.container.js +++ b/ui/app/components/currency-display/currency-display.container.js @@ -3,13 +3,15 @@ import CurrencyDisplay from './currency-display.component' import { getValueFromWeiHex, formatCurrency } from '../../helpers/confirm-transaction/util' const mapStateToProps = (state, ownProps) => { - const { value, numberOfDecimals = 2, currency } = ownProps + const { value, numberOfDecimals = 2, currency, denomination, hideLabel } = ownProps const { metamask: { currentCurrency, conversionRate } } = state const toCurrency = currency || currentCurrency - const convertedValue = getValueFromWeiHex({ value, toCurrency, conversionRate, numberOfDecimals }) + const convertedValue = getValueFromWeiHex({ + value, toCurrency, conversionRate, numberOfDecimals, toDenomination: denomination, + }) const formattedValue = formatCurrency(convertedValue, toCurrency) - const displayValue = `${formattedValue} ${toCurrency.toUpperCase()}` + const displayValue = hideLabel ? formattedValue : `${formattedValue} ${toCurrency.toUpperCase()}` return { displayValue, diff --git a/ui/app/components/currency-display/tests/currency-display.container.test.js b/ui/app/components/currency-display/tests/currency-display.container.test.js index 474ce5378..5265bbb04 100644 --- a/ui/app/components/currency-display/tests/currency-display.container.test.js +++ b/ui/app/components/currency-display/tests/currency-display.container.test.js @@ -51,6 +51,50 @@ describe('CurrencyDisplay container', () => { displayValue: '1.266 ETH', }, }, + { + props: { + value: '0x1193461d01595930', + currency: 'ETH', + numberOfDecimals: 3, + hideLabel: true, + }, + result: { + displayValue: '1.266', + }, + }, + { + props: { + value: '0x3b9aca00', + currency: 'ETH', + denomination: 'GWEI', + hideLabel: true, + }, + result: { + displayValue: '1', + }, + }, + { + props: { + value: '0x3b9aca00', + currency: 'ETH', + denomination: 'WEI', + hideLabel: true, + }, + result: { + displayValue: '1000000000', + }, + }, + { + props: { + value: '0x3b9aca00', + currency: 'ETH', + numberOfDecimals: 100, + hideLabel: true, + }, + result: { + displayValue: '1e-9', + }, + }, ] tests.forEach(({ props, result }) => { diff --git a/ui/app/components/customize-gas-modal/index.js b/ui/app/components/customize-gas-modal/index.js index c255fd64d..e67fbe45b 100644 --- a/ui/app/components/customize-gas-modal/index.js +++ b/ui/app/components/customize-gas-modal/index.js @@ -5,6 +5,7 @@ const inherits = require('util').inherits const connect = require('react-redux').connect const actions = require('../../actions') const GasModalCard = require('./gas-modal-card') +import Button from '../button' const ethUtil = require('ethereumjs-util') @@ -353,16 +354,16 @@ CustomizeGasModal.prototype.render = function () { }, [this.context.t('revert')]), h('div.send-v2__customize-gas__buttons', [ - h('button.btn-default.send-v2__customize-gas__cancel', { + h(Button, { + type: 'default', + className: 'send-v2__customize-gas__cancel', onClick: this.props.hideModal, - style: { - marginRight: '10px', - }, }, [this.context.t('cancel')]), - - h('button.btn-primary.send-v2__customize-gas__save', { + h(Button, { + type: 'primary', + className: 'send-v2__customize-gas__save', onClick: () => !error && this.save(newGasPrice, gasLimit, gasTotal), - className: error && 'btn-primary--disabled', + disabled: error, }, [this.context.t('save')]), ]), diff --git a/ui/app/components/hex-as-decimal-input.js b/ui/app/components/hex-as-decimal-input.js deleted file mode 100644 index 75303a34a..000000000 --- a/ui/app/components/hex-as-decimal-input.js +++ /dev/null @@ -1,161 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const inherits = require('util').inherits -const ethUtil = require('ethereumjs-util') -const BN = ethUtil.BN -const extend = require('xtend') -const connect = require('react-redux').connect - -HexAsDecimalInput.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect()(HexAsDecimalInput) - - -inherits(HexAsDecimalInput, Component) -function HexAsDecimalInput () { - this.state = { invalid: null } - Component.call(this) -} - -/* Hex as Decimal Input - * - * A component for allowing easy, decimal editing - * of a passed in hex string value. - * - * On change, calls back its `onChange` function parameter - * and passes it an updated hex string. - */ - -HexAsDecimalInput.prototype.render = function () { - const props = this.props - const state = this.state - - const { value, onChange, min, max } = props - - const toEth = props.toEth - const suffix = props.suffix - const decimalValue = decimalize(value, toEth) - const style = props.style - - return ( - h('.flex-column', [ - h('.flex-row', { - style: { - alignItems: 'flex-end', - lineHeight: '13px', - fontFamily: 'Montserrat Light', - textRendering: 'geometricPrecision', - }, - }, [ - h('input.hex-input', { - type: 'number', - required: true, - min: min, - max: max, - style: extend({ - display: 'block', - textAlign: 'right', - backgroundColor: 'transparent', - border: '1px solid #bdbdbd', - - }, style), - value: parseInt(decimalValue), - onBlur: (event) => { - this.updateValidity(event) - }, - onChange: (event) => { - this.updateValidity(event) - const hexString = (event.target.value === '') ? '' : hexify(event.target.value) - onChange(hexString) - }, - onInvalid: (event) => { - const msg = this.constructWarning() - if (msg === state.invalid) { - return - } - this.setState({ invalid: msg }) - event.preventDefault() - return false - }, - }), - h('div', { - style: { - color: ' #AEAEAE', - fontSize: '12px', - marginLeft: '5px', - marginRight: '6px', - width: '20px', - }, - }, suffix), - ]), - - state.invalid ? h('span.error', { - style: { - position: 'absolute', - right: '0px', - textAlign: 'right', - transform: 'translateY(26px)', - padding: '3px', - background: 'rgba(255,255,255,0.85)', - zIndex: '1', - textTransform: 'capitalize', - border: '2px solid #E20202', - }, - }, state.invalid) : null, - ]) - ) -} - -HexAsDecimalInput.prototype.setValid = function (message) { - this.setState({ invalid: null }) -} - -HexAsDecimalInput.prototype.updateValidity = function (event) { - const target = event.target - const value = this.props.value - const newValue = target.value - - if (value === newValue) { - return - } - - const valid = target.checkValidity() - if (valid) { - this.setState({ invalid: null }) - } -} - -HexAsDecimalInput.prototype.constructWarning = function () { - const { name, min, max } = this.props - let message = name ? name + ' ' : '' - - if (min && max) { - message += this.context.t('betweenMinAndMax', [min, max]) - } else if (min) { - message += this.context.t('greaterThanMin', [min]) - } else if (max) { - message += this.context.t('lessThanMax', [max]) - } else { - message += this.context.t('invalidInput') - } - - return message -} - -function hexify (decimalString) { - const hexBN = new BN(parseInt(decimalString), 10) - return '0x' + hexBN.toString('hex') -} - -function decimalize (input, toEth) { - if (input === '') { - return '' - } else { - const strippedInput = ethUtil.stripHexPrefix(input) - const inputBN = new BN(strippedInput, 'hex') - return inputBN.toString(10) - } -} diff --git a/ui/app/components/hex-to-decimal/hex-to-decimal.component.js b/ui/app/components/hex-to-decimal/hex-to-decimal.component.js new file mode 100644 index 000000000..6847a6919 --- /dev/null +++ b/ui/app/components/hex-to-decimal/hex-to-decimal.component.js @@ -0,0 +1,21 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { hexToDecimal } from '../../helpers/conversions.util' + +export default class HexToDecimal extends PureComponent { + static propTypes = { + className: PropTypes.string, + value: PropTypes.string, + } + + render () { + const { className, value } = this.props + const decimalValue = hexToDecimal(value) + + return ( + <div className={className}> + { decimalValue } + </div> + ) + } +} diff --git a/ui/app/components/hex-to-decimal/index.js b/ui/app/components/hex-to-decimal/index.js new file mode 100644 index 000000000..6e8567ca9 --- /dev/null +++ b/ui/app/components/hex-to-decimal/index.js @@ -0,0 +1 @@ +export { default } from './hex-to-decimal.component' diff --git a/ui/app/components/hex-to-decimal/tests/hex-to-decimal.component.test.js b/ui/app/components/hex-to-decimal/tests/hex-to-decimal.component.test.js new file mode 100644 index 000000000..c98da9ad4 --- /dev/null +++ b/ui/app/components/hex-to-decimal/tests/hex-to-decimal.component.test.js @@ -0,0 +1,26 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import HexToDecimal from '../hex-to-decimal.component' + +describe('HexToDecimal Component', () => { + it('should render a prefixed hex as a decimal with a className', () => { + const wrapper = shallow(<HexToDecimal + value="0x3039" + className="hex-to-decimal" + />) + + assert.ok(wrapper.hasClass('hex-to-decimal')) + assert.equal(wrapper.text(), '12345') + }) + + it('should render an unprefixed hex as a decimal with a className', () => { + const wrapper = shallow(<HexToDecimal + value="1A85" + className="hex-to-decimal" + />) + + assert.ok(wrapper.hasClass('hex-to-decimal')) + assert.equal(wrapper.text(), '6789') + }) +}) diff --git a/ui/app/components/identicon.js b/ui/app/components/identicon.js index 076e65b81..7bd921892 100644 --- a/ui/app/components/identicon.js +++ b/ui/app/components/identicon.js @@ -56,6 +56,7 @@ IdenticonComponent.prototype.render = function () { }) } else { return h('img.balance-icon', { + className, src: './images/eth_logo.svg', style: { ...style, diff --git a/ui/app/components/index.scss b/ui/app/components/index.scss index cb4065fd9..983d6b98a 100644 --- a/ui/app/components/index.scss +++ b/ui/app/components/index.scss @@ -2,6 +2,8 @@ @import './button-group/index'; +@import './card/index'; + @import './confirm-page-container/index'; @import './export-text-container/index'; @@ -24,6 +26,10 @@ @import './tabs/index'; +@import './transaction-activity-log/index'; + +@import './transaction-breakdown/index'; + @import './transaction-view/index'; @import './transaction-view-balance/index'; @@ -32,6 +38,8 @@ @import './transaction-list-item/index'; +@import './transaction-list-item-details/index'; + @import './transaction-status/index'; @import './app-header/index'; diff --git a/ui/app/components/modals/account-details-modal.js b/ui/app/components/modals/account-details-modal.js index bc577fda0..248ffe008 100644 --- a/ui/app/components/modals/account-details-modal.js +++ b/ui/app/components/modals/account-details-modal.js @@ -10,6 +10,8 @@ const genAccountLink = require('../../../lib/account-link.js') const QrView = require('../qr-code') const EditableLabel = require('../editable-label') +import Button from '../button' + function mapStateToProps (state) { return { network: state.metamask.network, @@ -80,12 +82,17 @@ AccountDetailsModal.prototype.render = function () { h('div.account-modal-divider'), - h('button.btn-primary.account-modal__button', { + h(Button, { + type: 'primary', + className: 'account-modal__button', onClick: () => global.platform.openWindow({ url: genAccountLink(address, network) }), }, this.context.t('etherscanView')), // Holding on redesign for Export Private Key functionality - exportPrivateKeyFeatureEnabled ? h('button.btn-primary.account-modal__button', { + + exportPrivateKeyFeatureEnabled ? h(Button, { + type: 'primary', + className: 'account-modal__button', onClick: () => showExportPrivateKeyModal(), }, this.context.t('exportPrivateKey')) : null, diff --git a/ui/app/components/modals/customize-gas/customize-gas.component.js b/ui/app/components/modals/customize-gas/customize-gas.component.js index 0337c5413..3f526bd43 100644 --- a/ui/app/components/modals/customize-gas/customize-gas.component.js +++ b/ui/app/components/modals/customize-gas/customize-gas.component.js @@ -2,6 +2,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import GasModalCard from '../../customize-gas-modal/gas-modal-card' import { MIN_GAS_PRICE_GWEI } from '../../send/send.constants' +import Button from '../../button' import { getDecimalGasLimit, @@ -116,21 +117,23 @@ export default class CustomizeGas extends Component { { t('revert') } </div> <div className="customize-gas__buttons"> - <button - className="btn-default customize-gas__cancel" + <Button + type="default" + className="customize-gas__cancel" onClick={() => hideModal()} style={{ marginRight: '10px' }} > { t('cancel') } - </button> - <button - className="btn-primary customize-gas__save" + </Button> + <Button + type="primary" + className="customize-gas__save" onClick={() => this.handleSave()} style={{ marginRight: '10px' }} disabled={!valid} > { t('save') } - </button> + </Button> </div> </div> </div> diff --git a/ui/app/components/modals/deposit-ether-modal.js b/ui/app/components/modals/deposit-ether-modal.js index 2daa7fa1d..09137d39a 100644 --- a/ui/app/components/modals/deposit-ether-modal.js +++ b/ui/app/components/modals/deposit-ether-modal.js @@ -7,6 +7,8 @@ const actions = require('../../actions') const { getNetworkDisplayName } = require('../../../../app/scripts/controllers/network/util') const ShapeshiftForm = require('../shapeshift-form') +import Button from '../button' + let DIRECT_DEPOSIT_ROW_TITLE let DIRECT_DEPOSIT_ROW_TEXT let COINBASE_ROW_TITLE @@ -109,7 +111,10 @@ DepositEtherModal.prototype.renderRow = function ({ ]), !hideButton && h('div.deposit-ether-modal__buy-row__button', [ - h('button.btn-primary.btn--large.deposit-ether-modal__deposit-button', { + h(Button, { + type: 'primary', + className: 'deposit-ether-modal__deposit-button', + large: true, onClick: onButtonClick, }, [buttonLabel]), ]), diff --git a/ui/app/components/modals/export-private-key-modal.js b/ui/app/components/modals/export-private-key-modal.js index 60a416304..d3e3c9a56 100644 --- a/ui/app/components/modals/export-private-key-modal.js +++ b/ui/app/components/modals/export-private-key-modal.js @@ -11,6 +11,7 @@ const { getSelectedIdentity } = require('../../selectors') const ReadOnlyInput = require('../readonly-input') const copyToClipboard = require('copy-to-clipboard') const { checksumAddress } = require('../../util') +import Button from '../button' function mapStateToPropsFactory () { let selectedIdentity = null @@ -97,24 +98,31 @@ ExportPrivateKeyModal.prototype.renderPasswordInput = function (privateKey) { }) } -ExportPrivateKeyModal.prototype.renderButton = function (className, onClick, label) { - return h('button', { - className, - onClick, - }, label) -} - ExportPrivateKeyModal.prototype.renderButtons = function (privateKey, password, address, hideModal) { return h('div.export-private-key-buttons', {}, [ - !privateKey && this.renderButton( - 'btn-default btn--large export-private-key__button export-private-key__button--cancel', - () => hideModal(), - 'Cancel' - ), + !privateKey && h(Button, { + type: 'default', + large: true, + className: 'export-private-key__button export-private-key__button--cancel', + onClick: () => hideModal(), + }, this.context.t('cancel')), (privateKey - ? this.renderButton('btn-primary btn--large export-private-key__button', () => hideModal(), this.context.t('done')) - : this.renderButton('btn-primary btn--large export-private-key__button', () => this.exportAccountAndGetPrivateKey(this.state.password, address), this.context.t('confirm')) + ? ( + h(Button, { + type: 'primary', + large: true, + className: 'export-private-key__button', + onClick: () => hideModal(), + }, this.context.t('done')) + ) : ( + h(Button, { + type: 'primary', + large: true, + className: 'export-private-key__button', + onClick: () => this.exportAccountAndGetPrivateKey(this.state.password, address), + }, this.context.t('confirm')) + ) ), ]) diff --git a/ui/app/components/pages/create-account/connect-hardware/account-list.js b/ui/app/components/pages/create-account/connect-hardware/account-list.js index 488a189ea..2767b2e1f 100644 --- a/ui/app/components/pages/create-account/connect-hardware/account-list.js +++ b/ui/app/components/pages/create-account/connect-hardware/account-list.js @@ -3,6 +3,7 @@ const PropTypes = require('prop-types') const h = require('react-hyperscript') const genAccountLink = require('../../../../../lib/account-link.js') const Select = require('react-select').default +import Button from '../../../button' class AccountList extends Component { constructor (props, context) { @@ -143,22 +144,20 @@ class AccountList extends Component { } return h('div.new-account-connect-form__buttons', {}, [ - h( - 'button.btn-default.btn--large.new-account-connect-form__button', - { - onClick: this.props.onCancel.bind(this), - }, - [this.context.t('cancel')] - ), - - h( - `button.btn-primary.btn--large.new-account-connect-form__button.unlock ${disabled ? '.btn-primary--disabled' : ''}`, - { - onClick: this.props.onUnlockAccount.bind(this, this.props.device), - ...buttonProps, - }, - [this.context.t('unlock')] - ), + h(Button, { + type: 'default', + large: true, + className: 'new-account-connect-form__button', + onClick: this.props.onCancel.bind(this), + }, [this.context.t('cancel')]), + + h(Button, { + type: 'primary', + large: true, + className: 'new-account-connect-form__button unlock', + disabled, + onClick: this.props.onUnlockAccount.bind(this, this.props.device), + }, [this.context.t('unlock')]), ]) } diff --git a/ui/app/components/pages/create-account/connect-hardware/connect-screen.js b/ui/app/components/pages/create-account/connect-hardware/connect-screen.js index b3dfa4ee2..d3abf3119 100644 --- a/ui/app/components/pages/create-account/connect-hardware/connect-screen.js +++ b/ui/app/components/pages/create-account/connect-hardware/connect-screen.js @@ -1,6 +1,7 @@ const { Component } = require('react') const PropTypes = require('prop-types') const h = require('react-hyperscript') +import Button from '../../../button' class ConnectScreen extends Component { constructor (props, context) { @@ -60,13 +61,13 @@ class ConnectScreen extends Component { h('h3.hw-connect__title', {}, this.context.t('browserNotSupported')), h('p.hw-connect__msg', {}, this.context.t('chromeRequiredForHardwareWallets')), ]), - h( - 'button.btn-primary.btn--large', - { - onClick: () => global.platform.openWindow({ - url: 'https://google.com/chrome', - }), - }, + h(Button, { + type: 'primary', + large: true, + onClick: () => global.platform.openWindow({ + url: 'https://google.com/chrome', + }), + }, this.context.t('downloadGoogleChrome') ), ]) diff --git a/ui/app/components/pages/create-account/import-account/json.js b/ui/app/components/pages/create-account/import-account/json.js index dd57256a3..32b1065aa 100644 --- a/ui/app/components/pages/create-account/import-account/json.js +++ b/ui/app/components/pages/create-account/import-account/json.js @@ -8,6 +8,7 @@ const actions = require('../../../../actions') const FileInput = require('react-simple-file-input').default const { DEFAULT_ROUTE } = require('../../../../routes') const HELP_LINK = 'https://support.metamask.io/kb/article/7-importing-accounts' +import Button from '../../../button' class JsonImportSubview extends Component { constructor (props) { @@ -51,17 +52,19 @@ class JsonImportSubview extends Component { h('div.new-account-create-form__buttons', {}, [ - h('button.btn-default.new-account-create-form__button', { + h(Button, { + type: 'default', + large: true, + className: 'new-account-create-form__button', onClick: () => this.props.history.push(DEFAULT_ROUTE), - }, [ - this.context.t('cancel'), - ]), + }, [this.context.t('cancel')]), - h('button.btn-primary.new-account-create-form__button', { + h(Button, { + type: 'primary', + large: true, + className: 'new-account-create-form__button', onClick: () => this.createNewKeychain(), - }, [ - this.context.t('import'), - ]), + }, [this.context.t('import')]), ]), diff --git a/ui/app/components/pages/create-account/import-account/private-key.js b/ui/app/components/pages/create-account/import-account/private-key.js index 1db999f2f..8db1bfbdd 100644 --- a/ui/app/components/pages/create-account/import-account/private-key.js +++ b/ui/app/components/pages/create-account/import-account/private-key.js @@ -7,6 +7,7 @@ const PropTypes = require('prop-types') const connect = require('react-redux').connect const actions = require('../../../../actions') const { DEFAULT_ROUTE } = require('../../../../routes') +import Button from '../../../button' PrivateKeyImportView.contextTypes = { t: PropTypes.func, @@ -61,20 +62,22 @@ PrivateKeyImportView.prototype.render = function () { h('div.new-account-import-form__buttons', {}, [ - h('button.btn-default.btn--large.new-account-create-form__button', { + h(Button, { + type: 'default', + large: true, + className: 'new-account-create-form__button', onClick: () => { displayWarning(null) this.props.history.push(DEFAULT_ROUTE) }, - }, [ - this.context.t('cancel'), - ]), + }, [this.context.t('cancel')]), - h('button.btn-primary.btn--large.new-account-create-form__button', { + h(Button, { + type: 'primary', + large: true, + className: 'new-account-create-form__button', onClick: () => this.createNewKeychain(), - }, [ - this.context.t('import'), - ]), + }, [this.context.t('import')]), ]), diff --git a/ui/app/components/pages/create-account/new-account.js b/ui/app/components/pages/create-account/new-account.js index 402b8f03b..c9e4b113c 100644 --- a/ui/app/components/pages/create-account/new-account.js +++ b/ui/app/components/pages/create-account/new-account.js @@ -4,6 +4,7 @@ const h = require('react-hyperscript') const connect = require('react-redux').connect const actions = require('../../../actions') const { DEFAULT_ROUTE } = require('../../../routes') +import Button from '../../button' class NewAccountCreateForm extends Component { constructor (props, context) { @@ -38,20 +39,22 @@ class NewAccountCreateForm extends Component { h('div.new-account-create-form__buttons', {}, [ - h('button.btn-default.btn--large.new-account-create-form__button', { + h(Button, { + type: 'default', + large: true, + className: 'new-account-create-form__button', onClick: () => history.push(DEFAULT_ROUTE), - }, [ - this.context.t('cancel'), - ]), + }, [this.context.t('cancel')]), - h('button.btn-primary.btn--large.new-account-create-form__button', { + h(Button, { + type: 'primary', + large: true, + className:'new-account-create-form__button', onClick: () => { createAccount(newAccountName || defaultAccountName) .then(() => history.push(DEFAULT_ROUTE)) }, - }, [ - this.context.t('create'), - ]), + }, [this.context.t('create')]), ]), diff --git a/ui/app/components/pages/keychains/reveal-seed.js b/ui/app/components/pages/keychains/reveal-seed.js index 7d7d3f462..7782b541c 100644 --- a/ui/app/components/pages/keychains/reveal-seed.js +++ b/ui/app/components/pages/keychains/reveal-seed.js @@ -8,6 +8,8 @@ const { requestRevealSeedWords } = require('../../../actions') const { DEFAULT_ROUTE } = require('../../../routes') const ExportTextContainer = require('../../export-text-container') +import Button from '../../button' + const PASSWORD_PROMPT_SCREEN = 'PASSWORD_PROMPT_SCREEN' const REVEAL_SEED_SCREEN = 'REVEAL_SEED_SCREEN' @@ -106,10 +108,16 @@ class RevealSeedPage extends Component { renderPasswordPromptFooter () { return ( h('.page-container__footer', [ - h('button.btn-default.btn--large.page-container__footer-button', { + h(Button, { + type: 'default', + large: true, + className: 'page-container__footer-button', onClick: () => this.props.history.push(DEFAULT_ROUTE), }, this.context.t('cancel')), - h('button.btn-primary.btn--large.page-container__footer-button', { + h(Button, { + type: 'primary', + large: true, + className: 'page-container__footer-button', onClick: event => this.handleSubmit(event), disabled: this.state.password === '', }, this.context.t('next')), @@ -120,7 +128,10 @@ class RevealSeedPage extends Component { renderRevealSeedFooter () { return ( h('.page-container__footer', [ - h('button.btn-default.btn--large.page-container__footer-button', { + h(Button, { + type: 'default', + large: true, + className: 'page-container__footer-button', onClick: () => this.props.history.push(DEFAULT_ROUTE), }, this.context.t('close')), ]) diff --git a/ui/app/components/pages/settings/settings.js b/ui/app/components/pages/settings/settings.js index a5ea1b89c..423276cf3 100644 --- a/ui/app/components/pages/settings/settings.js +++ b/ui/app/components/pages/settings/settings.js @@ -13,6 +13,8 @@ const ToggleButton = require('react-toggle-button') const { REVEAL_SEED_ROUTE } = require('../../../routes') const locales = require('../../../../../app/_locales/index.json') +import Button from '../../button' + const getInfuraCurrencyOptions = () => { const sortedCurrencies = infuraCurrencies.objects.sort((a, b) => { return a.quote.name.toLocaleLowerCase().localeCompare(b.quote.name.toLocaleLowerCase()) @@ -241,7 +243,10 @@ class Settings extends Component { ]), h('div.settings__content-item', [ h('div.settings__content-item-col', [ - h('button.btn-primary.btn--large.settings__button', { + h(Button, { + type: 'primary', + large: true, + className: 'settings__button', onClick (event) { window.logStateString((err, result) => { if (err) { @@ -266,7 +271,10 @@ class Settings extends Component { h('div.settings__content-item', this.context.t('revealSeedWords')), h('div.settings__content-item', [ h('div.settings__content-item-col', [ - h('button.btn-primary.btn--large.settings__button--red', { + h(Button, { + type: 'primary', + large: true, + className: 'settings__button--red', onClick: event => { event.preventDefault() history.push(REVEAL_SEED_ROUTE) @@ -286,7 +294,10 @@ class Settings extends Component { h('div.settings__content-item', this.context.t('useOldUI')), h('div.settings__content-item', [ h('div.settings__content-item-col', [ - h('button.btn-primary.btn--large.settings__button--orange', { + h(Button, { + type: 'primary', + large: true, + className: 'settings__button--orange', onClick (event) { event.preventDefault() setFeatureFlagToBeta() @@ -305,7 +316,10 @@ class Settings extends Component { h('div.settings__content-item', this.context.t('resetAccount')), h('div.settings__content-item', [ h('div.settings__content-item-col', [ - h('button.btn-primary.btn--large.settings__button--orange', { + h(Button, { + type: 'primary', + large: true, + className: 'settings__button--orange', onClick (event) { event.preventDefault() showResetAccountConfirmationModal() diff --git a/ui/app/components/sender-to-recipient/index.scss b/ui/app/components/sender-to-recipient/index.scss index 656e30ddf..0ab0413be 100644 --- a/ui/app/components/sender-to-recipient/index.scss +++ b/ui/app/components/sender-to-recipient/index.scss @@ -80,13 +80,13 @@ justify-content: center; position: relative; flex: 0 0 auto; - padding: 8px; .sender-to-recipient { &__party { display: flex; flex-direction: row; align-items: center; + justify-content: center; flex: 1; border-radius: 4px; box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.08); @@ -111,7 +111,6 @@ } &__arrow-container { - padding: 0 2px; display: flex; justify-content: center; align-items: center; diff --git a/ui/app/components/sender-to-recipient/sender-to-recipient.component.js b/ui/app/components/sender-to-recipient/sender-to-recipient.component.js index 445a11d8a..61f77224d 100644 --- a/ui/app/components/sender-to-recipient/sender-to-recipient.component.js +++ b/ui/app/components/sender-to-recipient/sender-to-recipient.component.js @@ -115,7 +115,7 @@ export default class SenderToRecipient extends PureComponent { renderRecipientWithoutAddress () { return ( <div className="sender-to-recipient__party sender-to-recipient__party--recipient"> - <i className="fa fa-file-text-o" /> + { !this.props.addressOnly && <i className="fa fa-file-text-o" /> } <div className="sender-to-recipient__name"> { this.context.t('newContract') } </div> diff --git a/ui/app/components/shapeshift-form.js b/ui/app/components/shapeshift-form.js index 2c4ba40bf..a842bcc8b 100644 --- a/ui/app/components/shapeshift-form.js +++ b/ui/app/components/shapeshift-form.js @@ -9,6 +9,8 @@ const { shapeShiftSubview, pairUpdate, buyWithShapeShift } = require('../actions const { isValidAddress } = require('../util') const SimpleDropdown = require('./dropdowns/simple-dropdown') +import Button from './button' + function mapStateToProps (state) { const { coinOptions, @@ -242,8 +244,10 @@ ShapeshiftForm.prototype.render = function () { ]), - !depositAddress && h('button.btn-primary.btn--large.shapeshift-form__shapeshift-buy-btn', { - className: btnClass, + !depositAddress && h(Button, { + type: 'primary', + large: true, + className: `${btnClass} shapeshift-form__shapeshift-buy-btn`, disabled: !token, onClick: () => this.onBuyWithShapeShift(), }, [this.context.t('buy')]), diff --git a/ui/app/components/signature-request.js b/ui/app/components/signature-request.js index 2e0102d1a..2bfa350d3 100644 --- a/ui/app/components/signature-request.js +++ b/ui/app/components/signature-request.js @@ -23,6 +23,7 @@ const { } = require('../selectors.js') import { clearConfirmTransaction } from '../ducks/confirm-transaction.duck' +import Button from './button' const { DEFAULT_ROUTE } = require('../routes') @@ -248,7 +249,10 @@ SignatureRequest.prototype.renderFooter = function () { } return h('div.request-signature__footer', [ - h('button.btn-default.btn--large.request-signature__footer__cancel-button', { + h(Button, { + type: 'default', + large: true, + className: 'request-signature__footer__cancel-button', onClick: event => { cancel(event).then(() => { this.props.clearConfirmTransaction() @@ -256,7 +260,9 @@ SignatureRequest.prototype.renderFooter = function () { }) }, }, this.context.t('cancel')), - h('button.btn-primary.btn--large', { + h(Button, { + type: 'primary', + large: true, onClick: event => { sign(event).then(() => { this.props.clearConfirmTransaction() diff --git a/ui/app/components/transaction-activity-log/index.js b/ui/app/components/transaction-activity-log/index.js new file mode 100644 index 000000000..a33da15a3 --- /dev/null +++ b/ui/app/components/transaction-activity-log/index.js @@ -0,0 +1 @@ +export { default } from './transaction-activity-log.container' diff --git a/ui/app/components/transaction-activity-log/index.scss b/ui/app/components/transaction-activity-log/index.scss new file mode 100644 index 000000000..2324d44b1 --- /dev/null +++ b/ui/app/components/transaction-activity-log/index.scss @@ -0,0 +1,63 @@ +.transaction-activity-log { + &__card { + background: $white; + height: 100%; + } + + &__activities-container { + padding-top: 8px; + } + + &__activity { + padding: 4px 0; + display: flex; + flex-direction: row; + align-items: center; + position: relative; + + &::after { + content: ''; + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 6px; + border-right: 1px solid $scorpion; + } + + &:first-child::after { + height: 50%; + top: 50%; + } + + &:last-child::after { + height: 50%; + } + } + + &__activity-icon { + width: 13px; + height: 13px; + margin-right: 6px; + border-radius: 50%; + background: $scorpion; + flex: 0 0 auto; + } + + &__activity-text { + color: $scorpion; + font-size: .75rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__value { + display: inline; + font-weight: 500; + } + + b { + font-weight: 500; + } +} diff --git a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js new file mode 100644 index 000000000..8687dbbc7 --- /dev/null +++ b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js @@ -0,0 +1,35 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import TransactionActivityLog from '../transaction-activity-log.component' +import Card from '../../card' + +describe('TransactionActivityLog Component', () => { + it('should render properly', () => { + const transaction = { + history: [], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + const wrapper = shallow( + <TransactionActivityLog + transaction={transaction} + className="test-class" + />, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-activity-log')) + assert.ok(wrapper.hasClass('test-class')) + assert.equal(wrapper.find(Card).length, 1) + }) +}) diff --git a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.container.test.js b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.container.test.js new file mode 100644 index 000000000..85d56a6a2 --- /dev/null +++ b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.container.test.js @@ -0,0 +1,27 @@ +import assert from 'assert' +import proxyquire from 'proxyquire' + +let mapStateToProps + +proxyquire('../transaction-activity-log.container.js', { + 'react-redux': { + connect: ms => { + mapStateToProps = ms + return () => ({}) + }, + }, +}) + +describe('TransactionActivityLog container', () => { + describe('mapStateToProps()', () => { + it('should return the correct props', () => { + const mockState = { + metamask: { + conversionRate: 280.45, + }, + } + + assert.deepEqual(mapStateToProps(mockState), { conversionRate: 280.45 }) + }) + }) +}) diff --git a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js new file mode 100644 index 000000000..586500408 --- /dev/null +++ b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js @@ -0,0 +1,208 @@ +import assert from 'assert' +import { getActivities } from '../transaction-activity-log.util' + +describe('getActivities', () => { + it('should return no activities for an empty history', () => { + const transaction = { + history: [], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + assert.deepEqual(getActivities(transaction), []) + }) + + it('should return activities for a transaction\'s history', () => { + const transaction = { + history: [ + { + id: 5559712943815343, + loadingDefaults: true, + metamaskNetworkId: '3', + status: 'unapproved', + time: 1535507561452, + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + }, + [ + { + op: 'replace', + path: '/loadingDefaults', + timestamp: 1535507561515, + value: false, + }, + { + op: 'add', + path: '/gasPriceSpecified', + value: true, + }, + { + op: 'add', + path: '/gasLimitSpecified', + value: true, + }, + { + op: 'add', + path: '/estimatedGas', + value: '0x5208', + }, + ], + [ + { + note: '#newUnapprovedTransaction - adding the origin', + op: 'add', + path: '/origin', + timestamp: 1535507561516, + value: 'MetaMask', + }, + [], + ], + [ + { + note: 'confTx: user approved transaction', + op: 'replace', + path: '/txParams/gasPrice', + timestamp: 1535664571504, + value: '0x77359400', + }, + ], + [ + { + note: 'txStateManager: setting status to approved', + op: 'replace', + path: '/status', + timestamp: 1535507564302, + value: 'approved', + }, + ], + [ + { + note: 'transactions#approveTransaction', + op: 'add', + path: '/txParams/nonce', + timestamp: 1535507564439, + value: '0xa4', + }, + { + op: 'add', + path: '/nonceDetails', + value: { + local: {}, + network: {}, + params: {}, + }, + }, + ], + [ + { + note: 'transactions#publishTransaction', + op: 'replace', + path: '/status', + timestamp: 1535507564518, + value: 'signed', + }, + { + op: 'add', + path: '/rawTx', + value: '0xf86b81a4843b9aca008252089450a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706872386f26fc10000802aa007b30119fc4fc5954fad727895b7e3ba80a78d197e95703cc603bcf017879151a01c50beda40ffaee541da9c05b9616247074f25f392800e0ad6c7a835d5366edf', + }, + ], + [], + [ + { + note: 'transactions#setTxHash', + op: 'add', + path: '/hash', + timestamp: 1535507564658, + value: '0x7acc4987b5c0dfa8d423798a8c561138259de1f98a62e3d52e7e83c0e0dd9fb7', + }, + ], + [ + { + note: 'txStateManager - add submitted time stamp', + op: 'add', + path: '/submittedTime', + timestamp: 1535507564660, + value: 1535507564660, + }, + ], + [ + { + note: 'txStateManager: setting status to submitted', + op: 'replace', + path: '/status', + timestamp: 1535507564665, + value: 'submitted', + }, + ], + [ + { + note: 'transactions/pending-tx-tracker#event: tx:block-update', + op: 'add', + path: '/firstRetryBlockNumber', + timestamp: 1535507575476, + value: '0x3bf624', + }, + ], + [ + { + note: 'txStateManager: setting status to confirmed', + op: 'replace', + path: '/status', + timestamp: 1535507615993, + value: 'confirmed', + }, + ], + ], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + const expectedResult = [ + { + 'eventKey': 'transactionCreated', + 'timestamp': 1535507561452, + 'value': '0x2386f26fc10000', + }, + { + 'eventKey': 'transactionUpdatedGas', + 'timestamp': 1535664571504, + 'value': '0x77359400', + }, + { + 'eventKey': 'transactionSubmitted', + 'timestamp': 1535507564665, + 'value': undefined, + }, + { + 'eventKey': 'transactionConfirmed', + 'timestamp': 1535507615993, + 'value': undefined, + }, + ] + + assert.deepEqual(getActivities(transaction), expectedResult) + }) +}) diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.component.js b/ui/app/components/transaction-activity-log/transaction-activity-log.component.js new file mode 100644 index 000000000..c4cf57d14 --- /dev/null +++ b/ui/app/components/transaction-activity-log/transaction-activity-log.component.js @@ -0,0 +1,91 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { getActivities } from './transaction-activity-log.util' +import Card from '../card' +import { getEthConversionFromWeiHex, getValueFromWeiHex } from '../../helpers/conversions.util' +import { ETH } from '../../constants/common' +import { formatDate } from '../../util' + +export default class TransactionActivityLog extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + transaction: PropTypes.object, + className: PropTypes.string, + conversionRate: PropTypes.number, + } + + state = { + activities: [], + } + + componentDidMount () { + this.setActivites() + } + + componentDidUpdate (prevProps) { + const { transaction: { history: prevHistory = [] } = {} } = prevProps + const { transaction: { history = [] } = {} } = this.props + + if (prevHistory.length !== history.length) { + this.setActivites() + } + } + + setActivites () { + const activities = getActivities(this.props.transaction) + this.setState({ activities }) + } + + renderActivity (activity, index) { + const { conversionRate } = this.props + const { eventKey, value, timestamp } = activity + const ethValue = index === 0 + ? `${getValueFromWeiHex({ + value, + toCurrency: ETH, + conversionRate, + numberOfDecimals: 6, + })} ${ETH}` + : getEthConversionFromWeiHex({ value, toCurrency: ETH, conversionRate }) + const formattedTimestamp = formatDate(timestamp) + const activityText = this.context.t(eventKey, [ethValue, formattedTimestamp]) + + return ( + <div + key={index} + className="transaction-activity-log__activity" + > + <div className="transaction-activity-log__activity-icon" /> + <div + className="transaction-activity-log__activity-text" + title={activityText} + > + { activityText } + </div> + </div> + ) + } + + render () { + const { t } = this.context + const { className } = this.props + const { activities } = this.state + + return ( + <div className={classnames('transaction-activity-log', className)}> + <Card + title={t('activityLog')} + className="transaction-activity-log__card" + > + <div className="transaction-activity-log__activities-container"> + { activities.map((activity, index) => this.renderActivity(activity, index)) } + </div> + </Card> + </div> + ) + } +} diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.container.js b/ui/app/components/transaction-activity-log/transaction-activity-log.container.js new file mode 100644 index 000000000..4c8b6d971 --- /dev/null +++ b/ui/app/components/transaction-activity-log/transaction-activity-log.container.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux' +import TransactionActivityLog from './transaction-activity-log.component' +import { conversionRateSelector } from '../../selectors' + +const mapStateToProps = state => { + return { + conversionRate: conversionRateSelector(state), + } +} + +export default connect(mapStateToProps)(TransactionActivityLog) diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.util.js b/ui/app/components/transaction-activity-log/transaction-activity-log.util.js new file mode 100644 index 000000000..32834ff47 --- /dev/null +++ b/ui/app/components/transaction-activity-log/transaction-activity-log.util.js @@ -0,0 +1,82 @@ +// path constants +const STATUS_PATH = '/status' +const GAS_PRICE_PATH = '/txParams/gasPrice' + +// status constants +const UNAPPROVED_STATUS = 'unapproved' +const SUBMITTED_STATUS = 'submitted' +const CONFIRMED_STATUS = 'confirmed' +const DROPPED_STATUS = 'dropped' + +// op constants +const REPLACE_OP = 'replace' + +// event constants +const TRANSACTION_CREATED_EVENT = 'transactionCreated' +const TRANSACTION_UPDATED_GAS_EVENT = 'transactionUpdatedGas' +const TRANSACTION_SUBMITTED_EVENT = 'transactionSubmitted' +const TRANSACTION_CONFIRMED_EVENT = 'transactionConfirmed' +const TRANSACTION_DROPPED_EVENT = 'transactionDropped' +const TRANSACTION_UPDATED_EVENT = 'transactionUpdated' + +const eventPathsHash = { + [STATUS_PATH]: true, + [GAS_PRICE_PATH]: true, +} + +const statusHash = { + [SUBMITTED_STATUS]: TRANSACTION_SUBMITTED_EVENT, + [CONFIRMED_STATUS]: TRANSACTION_CONFIRMED_EVENT, + [DROPPED_STATUS]: TRANSACTION_DROPPED_EVENT, +} + +function eventCreator (eventKey, timestamp, value) { + return { + eventKey, + timestamp, + value, + } +} + +export function getActivities (transaction) { + const { history = [] } = transaction + + return history.reduce((acc, base) => { + // First history item should be transaction creation + if (!Array.isArray(base) && base.status === UNAPPROVED_STATUS && base.txParams) { + const { time, txParams: { value } = {} } = base + return acc.concat(eventCreator(TRANSACTION_CREATED_EVENT, time, value)) + } else if (Array.isArray(base)) { + const events = [] + + base.forEach(entry => { + const { op, path, value, timestamp } = entry + + if (path in eventPathsHash && op === REPLACE_OP) { + switch (path) { + case STATUS_PATH: { + if (value in statusHash) { + events.push(eventCreator(statusHash[value], timestamp)) + } + + break + } + + case GAS_PRICE_PATH: { + events.push(eventCreator(TRANSACTION_UPDATED_GAS_EVENT, timestamp, value)) + break + } + + default: { + events.push(eventCreator(TRANSACTION_UPDATED_EVENT, timestamp)) + } + } + } + }) + + return acc.concat(events) + } + + return acc + }, []) +} diff --git a/ui/app/components/transaction-breakdown/index.js b/ui/app/components/transaction-breakdown/index.js new file mode 100644 index 000000000..c887f504f --- /dev/null +++ b/ui/app/components/transaction-breakdown/index.js @@ -0,0 +1 @@ +export { default } from './transaction-breakdown.component' diff --git a/ui/app/components/transaction-breakdown/index.scss b/ui/app/components/transaction-breakdown/index.scss new file mode 100644 index 000000000..1bb108943 --- /dev/null +++ b/ui/app/components/transaction-breakdown/index.scss @@ -0,0 +1,23 @@ +@import './transaction-breakdown-row/index'; + +.transaction-breakdown { + &__card { + background: $white; + height: 100%; + } + + &__row-title { + text-transform: capitalize; + } + + &__value { + text-align: end; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &--eth-total { + font-weight: 500; + } + } +} diff --git a/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js b/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js new file mode 100644 index 000000000..d18cd420c --- /dev/null +++ b/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js @@ -0,0 +1,37 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import TransactionBreakdown from '../transaction-breakdown.component' +import TransactionBreakdownRow from '../transaction-breakdown-row' +import Card from '../../card' + +describe('TransactionBreakdown Component', () => { + it('should render properly', () => { + const transaction = { + history: [], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + const wrapper = shallow( + <TransactionBreakdown + transaction={transaction} + className="test-class" + />, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-breakdown')) + assert.ok(wrapper.hasClass('test-class')) + assert.equal(wrapper.find(Card).length, 1) + assert.equal(wrapper.find(Card).find(TransactionBreakdownRow).length, 4) + }) +}) diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.js b/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.js new file mode 100644 index 000000000..557bf75fb --- /dev/null +++ b/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.js @@ -0,0 +1 @@ +export { default } from './transaction-breakdown-row.component' diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.scss b/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.scss new file mode 100644 index 000000000..8c73be1a6 --- /dev/null +++ b/ui/app/components/transaction-breakdown/transaction-breakdown-row/index.scss @@ -0,0 +1,19 @@ +.transaction-breakdown-row { + font-size: .75rem; + color: $scorpion; + display: flex; + justify-content: space-between; + padding: 8px 0; + + &:not(:last-child) { + border-bottom: 1px solid #d8d8d8; + } + + &__title { + padding-right: 8px; + } + + &__value { + min-width: 0; + } +} diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js b/ui/app/components/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js new file mode 100644 index 000000000..c19399dbb --- /dev/null +++ b/ui/app/components/transaction-breakdown/transaction-breakdown-row/tests/transaction-breakdown-row.component.test.js @@ -0,0 +1,39 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import TransactionBreakdownRow from '../transaction-breakdown-row.component' +import Button from '../../../button' + +describe('TransactionBreakdownRow Component', () => { + it('should render text properly', () => { + const wrapper = shallow( + <TransactionBreakdownRow + title="test" + className="test-class" + > + Test + </TransactionBreakdownRow>, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-breakdown-row')) + assert.equal(wrapper.find('.transaction-breakdown-row__title').text(), 'test') + assert.equal(wrapper.find('.transaction-breakdown-row__value').text(), 'Test') + }) + + it('should render components properly', () => { + const wrapper = shallow( + <TransactionBreakdownRow + title="test" + className="test-class" + > + <Button onClick={() => {}} >Button</Button> + </TransactionBreakdownRow>, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-breakdown-row')) + assert.equal(wrapper.find('.transaction-breakdown-row__title').text(), 'test') + assert.ok(wrapper.find('.transaction-breakdown-row__value').find(Button)) + }) +}) diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js b/ui/app/components/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js new file mode 100644 index 000000000..c11ff8efa --- /dev/null +++ b/ui/app/components/transaction-breakdown/transaction-breakdown-row/transaction-breakdown-row.component.js @@ -0,0 +1,26 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +export default class TransactionBreakdownRow extends PureComponent { + static propTypes = { + title: PropTypes.string, + children: PropTypes.node, + className: PropTypes.string, + } + + render () { + const { title, children, className } = this.props + + return ( + <div className={classnames('transaction-breakdown-row', className)}> + <div className="transaction-breakdown-row__title"> + { title } + </div> + <div className="transaction-breakdown-row__value"> + { children } + </div> + </div> + ) + } +} diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown.component.js b/ui/app/components/transaction-breakdown/transaction-breakdown.component.js new file mode 100644 index 000000000..bb6075e9f --- /dev/null +++ b/ui/app/components/transaction-breakdown/transaction-breakdown.component.js @@ -0,0 +1,82 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import TransactionBreakdownRow from './transaction-breakdown-row' +import Card from '../card' +import CurrencyDisplay from '../currency-display' +import HexToDecimal from '../hex-to-decimal' +import { ETH, GWEI } from '../../constants/common' +import { getHexGasTotal } from '../../helpers/confirm-transaction/util' +import { sumHexes } from '../../helpers/transactions.util' + +export default class TransactionBreakdown extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + transaction: PropTypes.object, + className: PropTypes.string, + } + + static defaultProps = { + transaction: {}, + } + + render () { + const { t } = this.context + const { transaction, className } = this.props + const { txParams: { gas, gasPrice, value } = {} } = transaction + const hexGasTotal = getHexGasTotal({ gasLimit: gas, gasPrice }) + const totalInHex = sumHexes(hexGasTotal, value) + + return ( + <div className={classnames('transaction-breakdown', className)}> + <Card + title={t('transaction')} + className="transaction-breakdown__card" + > + <TransactionBreakdownRow title={t('amount')}> + <CurrencyDisplay + className="transaction-breakdown__value" + currency={ETH} + value={value} + /> + </TransactionBreakdownRow> + <TransactionBreakdownRow + title={`${t('gasLimit')} (${t('units')})`} + className="transaction-breakdown__row-title" + > + <HexToDecimal + className="transaction-breakdown__value" + value={gas} + /> + </TransactionBreakdownRow> + <TransactionBreakdownRow title={t('gasPrice')}> + <CurrencyDisplay + className="transaction-breakdown__value" + currency={ETH} + denomination={GWEI} + value={gasPrice} + hideLabel + /> + </TransactionBreakdownRow> + <TransactionBreakdownRow title={t('total')}> + <div> + <CurrencyDisplay + className="transaction-breakdown__value transaction-breakdown__value--eth-total" + currency={ETH} + value={totalInHex} + numberOfDecimals={6} + /> + <CurrencyDisplay + className="transaction-breakdown__value" + value={totalInHex} + /> + </div> + </TransactionBreakdownRow> + </Card> + </div> + ) + } +} diff --git a/ui/app/components/transaction-list-item-details/index.js b/ui/app/components/transaction-list-item-details/index.js new file mode 100644 index 000000000..0e878d032 --- /dev/null +++ b/ui/app/components/transaction-list-item-details/index.js @@ -0,0 +1 @@ +export { default } from './transaction-list-item-details.component' diff --git a/ui/app/components/transaction-list-item-details/index.scss b/ui/app/components/transaction-list-item-details/index.scss new file mode 100644 index 000000000..54cf834cc --- /dev/null +++ b/ui/app/components/transaction-list-item-details/index.scss @@ -0,0 +1,49 @@ +.transaction-list-item-details { + &__header { + margin-bottom: 8px; + display: flex; + justify-content: space-between; + align-items: center; + } + + &__header-buttons { + display: flex; + flex-direction: row; + } + + &__header-button { + font-size: .625rem; + + &:not(:last-child) { + margin-right: 8px; + } + } + + &__sender-to-recipient-container { + margin-bottom: 8px; + } + + &__cards-container { + display: flex; + flex-direction: row; + + @media screen and (max-width: $break-small) { + flex-direction: column; + } + } + + &__transaction-breakdown { + flex: 1; + margin-right: 8px; + min-width: 0; + + @media screen and (max-width: $break-small) { + margin: 0 0 8px 0; + } + } + + &__transaction-activity-log { + flex: 2; + min-width: 0; + } +} diff --git a/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js b/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js new file mode 100644 index 000000000..f2bbe8789 --- /dev/null +++ b/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js @@ -0,0 +1,66 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import TransactionListItemDetails from '../transaction-list-item-details.component' +import Button from '../../button' +import SenderToRecipient from '../../sender-to-recipient' +import TransactionBreakdown from '../../transaction-breakdown' +import TransactionActivityLog from '../../transaction-activity-log' + +describe('TransactionListItemDetails Component', () => { + it('should render properly', () => { + const transaction = { + history: [], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + const wrapper = shallow( + <TransactionListItemDetails + transaction={transaction} + />, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-list-item-details')) + assert.equal(wrapper.find(Button).length, 1) + assert.equal(wrapper.find(SenderToRecipient).length, 1) + assert.equal(wrapper.find(TransactionBreakdown).length, 1) + assert.equal(wrapper.find(TransactionActivityLog).length, 1) + }) + + it('should render a retry button', () => { + const transaction = { + history: [], + id: 1, + status: 'confirmed', + txParams: { + from: '0x1', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0xa4', + to: '0x2', + value: '0x2386f26fc10000', + }, + } + + const wrapper = shallow( + <TransactionListItemDetails + transaction={transaction} + showRetry={true} + />, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-list-item-details')) + assert.equal(wrapper.find(Button).length, 2) + }) +}) diff --git a/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js b/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js new file mode 100644 index 000000000..d57ff130a --- /dev/null +++ b/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js @@ -0,0 +1,80 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import SenderToRecipient from '../sender-to-recipient' +import { CARDS_VARIANT } from '../sender-to-recipient/sender-to-recipient.constants' +import TransactionActivityLog from '../transaction-activity-log' +import TransactionBreakdown from '../transaction-breakdown' +import Button from '../button' +import prefixForNetwork from '../../../lib/etherscan-prefix-for-network' + +export default class TransactionListItemDetails extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + transaction: PropTypes.object, + showRetry: PropTypes.bool, + } + + handleEtherscanClick = () => { + const { hash, metamaskNetworkId } = this.props.transaction + + const prefix = prefixForNetwork(metamaskNetworkId) + const etherscanUrl = `https://${prefix}etherscan.io/tx/${hash}` + global.platform.openWindow({ url: etherscanUrl }) + this.setState({ showTransactionDetails: true }) + } + + render () { + const { t } = this.context + const { transaction, showRetry } = this.props + const { txParams: { to, from } = {} } = transaction + + return ( + <div className="transaction-list-item-details"> + <div className="transaction-list-item-details__header"> + <div>Details</div> + <div className="transaction-list-item-details__header-buttons"> + { + showRetry && ( + <Button + type="raised" + onClick={this.handleEtherscanClick} + className="transaction-list-item-details__header-button" + > + { t('speedUp') } + </Button> + ) + } + <Button + type="raised" + onClick={this.handleEtherscanClick} + className="transaction-list-item-details__header-button" + > + <img src="/images/arrow-popout.svg" /> + </Button> + </div> + </div> + <div className="transaction-list-item-details__sender-to-recipient-container"> + <SenderToRecipient + variant={CARDS_VARIANT} + addressOnly + recipientAddress={to} + senderAddress={from} + /> + </div> + <div className="transaction-list-item-details__cards-container"> + <TransactionBreakdown + transaction={transaction} + className="transaction-list-item-details__transaction-breakdown" + /> + <TransactionActivityLog + transaction={transaction} + className="transaction-list-item-details__transaction-activity-log" + /> + </div> + </div> + ) + } +} diff --git a/ui/app/components/transaction-list-item/index.scss b/ui/app/components/transaction-list-item/index.scss index 9c53c8960..427686c29 100644 --- a/ui/app/components/transaction-list-item/index.scss +++ b/ui/app/components/transaction-list-item/index.scss @@ -1,37 +1,34 @@ .transaction-list-item { box-sizing: border-box; min-height: 74px; - padding: 8px 20px; border-bottom: 1px solid $geyser; - cursor: pointer; display: flex; justify-content: center; align-items: center; flex-direction: column; - @media screen and (max-width: $break-small) { - padding: 8px 20px 12px; - } - - &:hover { - background: rgba($alto, .2); - } - &__grid { + cursor: pointer; width: 100%; + padding: 16px 20px; display: grid; grid-template-columns: 45px 1fr 1fr 1fr; grid-template-areas: "identicon action status primary-amount" "identicon nonce status secondary-amount"; - @media screen and (max-width: $break-small) { - grid-template-columns: 45px 5fr 3fr; - grid-template-areas: - "nonce nonce nonce" - "identicon action primary-amount" - "identicon status secondary-amount"; - } + @media screen and (max-width: $break-small) { + padding: 8px 20px 12px; + grid-template-columns: 45px 5fr 3fr; + grid-template-areas: + "nonce nonce nonce" + "identicon action primary-amount" + "identicon status secondary-amount"; + } + + &:hover { + background: rgba($alto, .2); + } } &__identicon { @@ -114,4 +111,10 @@ font-size: .5rem; } } + + &__details-container { + padding: 8px 16px 16px; + background: #f3f4f7; + width: 100%; + } } diff --git a/ui/app/components/transaction-list-item/transaction-list-item.component.js b/ui/app/components/transaction-list-item/transaction-list-item.component.js index 75b41a477..5564f0883 100644 --- a/ui/app/components/transaction-list-item/transaction-list-item.component.js +++ b/ui/app/components/transaction-list-item/transaction-list-item.component.js @@ -5,7 +5,7 @@ import TransactionStatus from '../transaction-status' import TransactionAction from '../transaction-action' import CurrencyDisplay from '../currency-display' import TokenCurrencyDisplay from '../token-currency-display' -import prefixForNetwork from '../../../lib/etherscan-prefix-for-network' +import TransactionListItemDetails from '../transaction-list-item-details' import { CONFIRM_TRANSACTION_ROUTE } from '../../routes' import { UNAPPROVED_STATUS, TOKEN_METHOD_TRANSFER } from '../../constants/transactions' import { ETH } from '../../constants/common' @@ -22,19 +22,24 @@ export default class TransactionListItem extends PureComponent { nonceAndDate: PropTypes.string, token: PropTypes.object, assetImages: PropTypes.object, + tokenData: PropTypes.object, + } + + state = { + showTransactionDetails: false, } handleClick = () => { const { transaction, history } = this.props - const { id, status, hash, metamaskNetworkId } = transaction + const { id, status } = transaction + const { showTransactionDetails } = this.state if (status === UNAPPROVED_STATUS) { history.push(`${CONFIRM_TRANSACTION_ROUTE}/${id}`) - } else if (hash) { - const prefix = prefixForNetwork(metamaskNetworkId) - const etherscanUrl = `https://${prefix}etherscan.io/tx/${hash}` - global.platform.openWindow({ url: etherscanUrl }) + return } + + this.setState({ showTransactionDetails: !showTransactionDetails }) } handleRetryClick = event => { @@ -75,6 +80,8 @@ export default class TransactionListItem extends PureComponent { className="transaction-list-item__amount transaction-list-item__amount--primary" value={value} prefix="-" + numberOfDecimals={2} + currency={ETH} /> ) } @@ -89,8 +96,6 @@ export default class TransactionListItem extends PureComponent { className="transaction-list-item__amount transaction-list-item__amount--secondary" prefix="-" value={value} - numberOfDecimals={2} - currency={ETH} /> ) } @@ -102,20 +107,25 @@ export default class TransactionListItem extends PureComponent { showRetry, nonceAndDate, assetImages, + tokenData, } = this.props const { txParams = {} } = transaction + const { showTransactionDetails } = this.state + const toAddress = tokenData + ? tokenData.params && tokenData.params[0] && tokenData.params[0].value || txParams.to + : txParams.to return ( - <div - className="transaction-list-item" - onClick={this.handleClick} - > - <div className="transaction-list-item__grid"> + <div className="transaction-list-item"> + <div + className="transaction-list-item__grid" + onClick={this.handleClick} + > <Identicon className="transaction-list-item__identicon" - address={txParams.to} + address={toAddress} diameter={34} - image={assetImages[txParams.to]} + image={assetImages[toAddress]} /> <TransactionAction transaction={transaction} @@ -141,12 +151,12 @@ export default class TransactionListItem extends PureComponent { { this.renderSecondaryCurrency() } </div> { - showRetry && methodData.done && ( - <div - className="transaction-list-item__retry" - onClick={this.handleRetryClick} - > - <span>Taking too long? Increase the gas price on your transaction</span> + showTransactionDetails && ( + <div className="transaction-list-item__details-container"> + <TransactionListItemDetails + transaction={transaction} + showRetry={showRetry && methodData.done} + /> </div> ) } diff --git a/ui/app/components/transaction-list-item/transaction-list-item.container.js b/ui/app/components/transaction-list-item/transaction-list-item.container.js index 47644241a..3db9d40ec 100644 --- a/ui/app/components/transaction-list-item/transaction-list-item.container.js +++ b/ui/app/components/transaction-list-item/transaction-list-item.container.js @@ -5,16 +5,19 @@ import withMethodData from '../../higher-order-components/with-method-data' import TransactionListItem from './transaction-list-item.component' import { setSelectedToken, retryTransaction } from '../../actions' import { hexToDecimal } from '../../helpers/conversions.util' +import { getTokenData } from '../../helpers/transactions.util' import { formatDate } from '../../util' const mapStateToProps = (state, ownProps) => { - const { transaction: { txParams: { value, nonce } = {}, time } = {} } = ownProps + const { transaction: { txParams: { value, nonce, data } = {}, time } = {} } = ownProps + const tokenData = data && getTokenData(data) const nonceAndDate = nonce ? `#${hexToDecimal(nonce)} - ${formatDate(time)}` : formatDate(time) return { value, nonceAndDate, + tokenData, } } diff --git a/ui/app/components/transaction-list/index.scss b/ui/app/components/transaction-list/index.scss index 0e8db485c..d944ef20e 100644 --- a/ui/app/components/transaction-list/index.scss +++ b/ui/app/components/transaction-list/index.scss @@ -7,7 +7,7 @@ &__completed-transactions { display: flex; flex-direction: column; - height: 100%; + flex: 1; } &__header { @@ -35,6 +35,7 @@ flex: 1; display: grid; grid-template-rows: 35% 1fr; + padding-top: 8px; } &__empty-text { diff --git a/ui/app/components/wallet-view.js b/ui/app/components/wallet-view.js index 77c7f3a4b..064a6ab55 100644 --- a/ui/app/components/wallet-view.js +++ b/ui/app/components/wallet-view.js @@ -17,6 +17,8 @@ const TokenList = require('./token-list') const selectors = require('../selectors') const { ADD_TOKEN_ROUTE } = require('../routes') +import Button from './button' + module.exports = compose( withRouter, connect(mapStateToProps, mapDispatchToProps) @@ -199,7 +201,9 @@ WalletView.prototype.render = function () { h(TokenList), - h('button.btn-primary.wallet-view__add-token-button', { + h(Button, { + type: 'primary', + className: 'wallet-view__add-token-button', onClick: () => { history.push(ADD_TOKEN_ROUTE) sidebarOpen && hideSidebar() diff --git a/ui/app/constants/common.js b/ui/app/constants/common.js index 28731ce33..a20f6cc02 100644 --- a/ui/app/constants/common.js +++ b/ui/app/constants/common.js @@ -1 +1,3 @@ export const ETH = 'ETH' +export const GWEI = 'GWEI' +export const WEI = 'WEI' diff --git a/ui/app/conversion-util.js b/ui/app/conversion-util.js index 38f5f1c50..f271b5683 100644 --- a/ui/app/conversion-util.js +++ b/ui/app/conversion-util.js @@ -35,6 +35,7 @@ BigNumber.config({ // Big Number Constants const BIG_NUMBER_WEI_MULTIPLIER = new BigNumber('1000000000000000000') const BIG_NUMBER_GWEI_MULTIPLIER = new BigNumber('1000000000') +const BIG_NUMBER_ETH_MULTIPLIER = new BigNumber('1') // Individual Setters const convert = R.invoker(1, 'times') @@ -52,10 +53,12 @@ const toBigNumber = { const toNormalizedDenomination = { WEI: bigNumber => bigNumber.div(BIG_NUMBER_WEI_MULTIPLIER), GWEI: bigNumber => bigNumber.div(BIG_NUMBER_GWEI_MULTIPLIER), + ETH: bigNumber => bigNumber.div(BIG_NUMBER_ETH_MULTIPLIER), } const toSpecifiedDenomination = { WEI: bigNumber => bigNumber.times(BIG_NUMBER_WEI_MULTIPLIER).round(), GWEI: bigNumber => bigNumber.times(BIG_NUMBER_GWEI_MULTIPLIER).round(9), + ETH: bigNumber => bigNumber.times(BIG_NUMBER_ETH_MULTIPLIER).round(9), } const baseChange = { hex: n => n.toString(16), diff --git a/ui/app/css/itcss/components/buttons.scss b/ui/app/css/itcss/components/buttons.scss index 34565767f..655188a3e 100644 --- a/ui/app/css/itcss/components/buttons.scss +++ b/ui/app/css/itcss/components/buttons.scss @@ -2,10 +2,7 @@ Buttons */ -.btn-default, -.btn-primary, -.btn-secondary, -.btn-confirm { +.button { height: 44px; background: $white; display: flex; @@ -79,6 +76,16 @@ background-color: $curious-blue; } +.btn-raised { + color: $curious-blue; + background-color: $white; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.08); + padding: 6px; + height: initial; + width: initial; + min-width: initial; +} + .btn--large { height: 54px; } diff --git a/ui/app/css/itcss/components/send.scss b/ui/app/css/itcss/components/send.scss index 03c7e457c..4f97fc662 100644 --- a/ui/app/css/itcss/components/send.scss +++ b/ui/app/css/itcss/components/send.scss @@ -837,6 +837,10 @@ line-height: 12px; color: $red; } + + &__cancel { + margin-right: 10px; + } } &__gas-modal-card { diff --git a/ui/app/helpers/confirm-transaction/util.js b/ui/app/helpers/confirm-transaction/util.js index d1a4994e4..bcac22500 100644 --- a/ui/app/helpers/confirm-transaction/util.js +++ b/ui/app/helpers/confirm-transaction/util.js @@ -58,6 +58,7 @@ export function getValueFromWeiHex ({ toCurrency, conversionRate, numberOfDecimals, + toDenomination, }) { return conversionUtil(value, { fromNumericBase: 'hex', @@ -66,6 +67,7 @@ export function getValueFromWeiHex ({ toCurrency, numberOfDecimals, fromDenomination: 'WEI', + toDenomination, conversionRate, }) } diff --git a/ui/app/helpers/conversions.util.js b/ui/app/helpers/conversions.util.js index 1dec216fa..5204faa1f 100644 --- a/ui/app/helpers/conversions.util.js +++ b/ui/app/helpers/conversions.util.js @@ -1,4 +1,5 @@ import { conversionUtil } from '../conversion-util' +import { ETH, GWEI, WEI } from '../constants/common' export function hexToDecimal (hexValue) { return conversionUtil(hexValue, { @@ -7,16 +8,27 @@ export function hexToDecimal (hexValue) { }) } -export function getEthFromWeiHex ({ - value, - conversionRate, -}) { - return getValueFromWeiHex({ - value, - conversionRate, - toCurrency: 'ETH', - numberOfDecimals: 6, - }) +export function getEthConversionFromWeiHex ({ value, conversionRate, numberOfDecimals = 6 }) { + const denominations = [ETH, GWEI, WEI] + + let nonZeroDenomination + + for (let i = 0; i < denominations.length; i++) { + const convertedValue = getValueFromWeiHex({ + value, + conversionRate, + toCurrency: ETH, + numberOfDecimals, + toDenomination: denominations[i], + }) + + if (convertedValue !== '0' || i === denominations.length - 1) { + nonZeroDenomination = `${convertedValue} ${denominations[i]}` + break + } + } + + return nonZeroDenomination } export function getValueFromWeiHex ({ @@ -24,14 +36,16 @@ export function getValueFromWeiHex ({ toCurrency, conversionRate, numberOfDecimals, + toDenomination, }) { return conversionUtil(value, { fromNumericBase: 'hex', toNumericBase: 'dec', - fromCurrency: 'ETH', + fromCurrency: ETH, toCurrency, numberOfDecimals, - fromDenomination: 'WEI', + fromDenomination: WEI, + toDenomination, conversionRate, }) } diff --git a/ui/app/helpers/transactions.util.js b/ui/app/helpers/transactions.util.js index 54df54aa8..0e1a6ca37 100644 --- a/ui/app/helpers/transactions.util.js +++ b/ui/app/helpers/transactions.util.js @@ -16,6 +16,8 @@ import { UNKNOWN_FUNCTION_KEY, } from '../constants/transactions' +import { addCurrencies } from '../conversion-util' + abiDecoder.addABI(abi) export function getTokenData (data = {}) { @@ -103,3 +105,13 @@ export async function isSmartContractAddress (address) { const code = await global.eth.getCode(address) return code && code !== '0x' } + +export function sumHexes (...args) { + const total = args.reduce((acc, base) => { + return addCurrencies(acc, base, { + toNumericBase: 'hex', + }) + }) + + return ethUtil.addHexPrefix(total) +} diff --git a/ui/i18n-helper.js b/ui/i18n-helper.js index bc927ee65..c6a7d0bf1 100644 --- a/ui/i18n-helper.js +++ b/ui/i18n-helper.js @@ -20,10 +20,10 @@ const getMessage = (locale, key, substitutions) => { let phrase = entry.message // perform substitutions if (substitutions && substitutions.length) { - phrase = phrase.replace(/\$1/g, substitutions[0]) - if (substitutions.length > 1) { - phrase = phrase.replace(/\$2/g, substitutions[1]) - } + substitutions.forEach((substitution, index) => { + const regex = new RegExp(`\\$${index + 1}`, 'g') + phrase = phrase.replace(regex, substitution) + }) } return phrase } |