aboutsummaryrefslogtreecommitdiffstats
path: root/ui/app/components
diff options
context:
space:
mode:
Diffstat (limited to 'ui/app/components')
-rw-r--r--ui/app/components/button-group/button-group.component.js14
-rw-r--r--ui/app/components/button-group/tests/button-group-component.test.js14
-rw-r--r--ui/app/components/button/button.component.js6
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js207
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/index.js1
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/index.scss203
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/tests/advanced-tab-content-component.test.js364
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.js1
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.scss17
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/tests/time-remaining-component.test.js30
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.component.js33
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.utils.js11
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/basic-tab-content.component.js34
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/index.js1
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/index.scss28
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/tests/basic-tab-content-component.test.js82
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js186
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js286
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/index.js1
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/index.scss148
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js274
-rw-r--r--ui/app/components/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js362
-rw-r--r--ui/app/components/gas-customization/gas-price-button-group/gas-price-button-group.component.js89
-rw-r--r--ui/app/components/gas-customization/gas-price-button-group/index.js1
-rw-r--r--ui/app/components/gas-customization/gas-price-button-group/index.scss235
-rw-r--r--ui/app/components/gas-customization/gas-price-button-group/tests/gas-price-button-group-component.test.js233
-rw-r--r--ui/app/components/gas-customization/gas-price-chart/gas-price-chart.component.js108
-rw-r--r--ui/app/components/gas-customization/gas-price-chart/gas-price-chart.utils.js354
-rw-r--r--ui/app/components/gas-customization/gas-price-chart/index.js1
-rw-r--r--ui/app/components/gas-customization/gas-price-chart/index.scss132
-rw-r--r--ui/app/components/gas-customization/gas-price-chart/tests/gas-price-chart.component.test.js218
-rw-r--r--ui/app/components/gas-customization/gas-slider/gas-slider.component.js48
-rw-r--r--ui/app/components/gas-customization/gas-slider/index.js1
-rw-r--r--ui/app/components/gas-customization/gas-slider/index.scss54
-rw-r--r--ui/app/components/gas-customization/gas.selectors.js14
-rw-r--r--ui/app/components/gas-customization/index.scss5
-rw-r--r--ui/app/components/index.scss10
-rw-r--r--ui/app/components/modals/cancel-transaction/cancel-transaction.container.js16
-rw-r--r--ui/app/components/modals/modal.js50
-rw-r--r--ui/app/components/page-container/index.scss3
-rw-r--r--ui/app/components/page-container/page-container-footer/page-container-footer.component.js6
-rw-r--r--ui/app/components/page-container/page-container-header/page-container-header.component.js13
-rw-r--r--ui/app/components/page-container/page-container.component.js29
-rw-r--r--ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js2
-rw-r--r--ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js16
-rw-r--r--ui/app/components/pages/confirm-transaction/confirm-transaction.component.js3
-rw-r--r--ui/app/components/pages/confirm-transaction/confirm-transaction.container.js4
-rw-r--r--ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js11
-rw-r--r--ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js18
-rw-r--r--ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js36
-rw-r--r--ui/app/components/send/send-content/send-gas-row/send-gas-row.container.js59
-rw-r--r--ui/app/components/send/send-content/send-gas-row/send-gas-row.selectors.js5
-rw-r--r--ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js42
-rw-r--r--ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-container.test.js94
-rw-r--r--ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js13
-rw-r--r--ui/app/components/send/send.component.js14
-rw-r--r--ui/app/components/send/send.container.js8
-rw-r--r--ui/app/components/send/send.selectors.js10
-rw-r--r--ui/app/components/send/tests/send-component.test.js50
-rw-r--r--ui/app/components/send/tests/send-container.test.js10
-rw-r--r--ui/app/components/send/tests/send-selectors.test.js2
-rw-r--r--ui/app/components/sender-to-recipient/index.scss55
-rw-r--r--ui/app/components/sender-to-recipient/sender-to-recipient.component.js23
-rw-r--r--ui/app/components/sender-to-recipient/sender-to-recipient.constants.js1
-rw-r--r--ui/app/components/sidebars/index.scss7
-rw-r--r--ui/app/components/sidebars/sidebar-content.scss112
-rw-r--r--ui/app/components/sidebars/sidebar.component.js21
-rw-r--r--ui/app/components/sidebars/tests/sidebars-component.test.js9
-rw-r--r--ui/app/components/signature-request.js2
-rw-r--r--ui/app/components/transaction-activity-log/index.scss40
-rw-r--r--ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js94
-rw-r--r--ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js145
-rw-r--r--ui/app/components/transaction-activity-log/transaction-activity-log-icon/index.js1
-rw-r--r--ui/app/components/transaction-activity-log/transaction-activity-log-icon/transaction-activity-log-icon.component.js55
-rw-r--r--ui/app/components/transaction-activity-log/transaction-activity-log.component.js114
-rw-r--r--ui/app/components/transaction-activity-log/transaction-activity-log.constants.js13
-rw-r--r--ui/app/components/transaction-activity-log/transaction-activity-log.container.js34
-rw-r--r--ui/app/components/transaction-activity-log/transaction-activity-log.util.js199
-rw-r--r--ui/app/components/transaction-breakdown/index.scss7
-rw-r--r--ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js4
-rw-r--r--ui/app/components/transaction-breakdown/transaction-breakdown.component.js101
-rw-r--r--ui/app/components/transaction-list-item-details/index.scss11
-rw-r--r--ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js19
-rw-r--r--ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js60
-rw-r--r--ui/app/components/transaction-list-item/index.scss6
-rw-r--r--ui/app/components/transaction-list-item/transaction-list-item.component.js57
-rw-r--r--ui/app/components/transaction-list-item/transaction-list-item.container.js57
-rw-r--r--ui/app/components/transaction-list/transaction-list.component.js43
-rw-r--r--ui/app/components/transaction-list/transaction-list.container.js15
-rw-r--r--ui/app/components/transaction-status/index.scss16
-rw-r--r--ui/app/components/transaction-status/tests/transaction-status.component.test.js8
-rw-r--r--ui/app/components/transaction-status/transaction-status.component.js7
92 files changed, 5229 insertions, 427 deletions
diff --git a/ui/app/components/button-group/button-group.component.js b/ui/app/components/button-group/button-group.component.js
index f99f710ce..17a281030 100644
--- a/ui/app/components/button-group/button-group.component.js
+++ b/ui/app/components/button-group/button-group.component.js
@@ -5,18 +5,30 @@ import classnames from 'classnames'
export default class ButtonGroup extends PureComponent {
static propTypes = {
defaultActiveButtonIndex: PropTypes.number,
+ noButtonActiveByDefault: PropTypes.bool,
disabled: PropTypes.bool,
children: PropTypes.array,
className: PropTypes.string,
style: PropTypes.object,
+ newActiveButtonIndex: PropTypes.number,
}
static defaultProps = {
className: 'button-group',
+ defaultActiveButtonIndex: 0,
}
state = {
- activeButtonIndex: this.props.defaultActiveButtonIndex || 0,
+ activeButtonIndex: this.props.noButtonActiveByDefault
+ ? null
+ : this.props.defaultActiveButtonIndex,
+ }
+
+ componentDidUpdate (_, prevState) {
+ // Provides an API for dynamically updating the activeButtonIndex
+ if (typeof this.props.newActiveButtonIndex === 'number' && prevState.activeButtonIndex !== this.props.newActiveButtonIndex) {
+ this.setState({ activeButtonIndex: this.props.newActiveButtonIndex })
+ }
}
handleButtonClick (activeButtonIndex) {
diff --git a/ui/app/components/button-group/tests/button-group-component.test.js b/ui/app/components/button-group/tests/button-group-component.test.js
index f07bb97c8..0bece90d6 100644
--- a/ui/app/components/button-group/tests/button-group-component.test.js
+++ b/ui/app/components/button-group/tests/button-group-component.test.js
@@ -35,6 +35,20 @@ describe('ButtonGroup Component', function () {
ButtonGroup.prototype.renderButtons.resetHistory()
})
+ describe('componentDidUpdate', () => {
+ it('should set the activeButtonIndex to the updated newActiveButtonIndex', () => {
+ assert.equal(wrapper.state('activeButtonIndex'), 1)
+ wrapper.setProps({ newActiveButtonIndex: 2 })
+ assert.equal(wrapper.state('activeButtonIndex'), 2)
+ })
+
+ it('should not set the activeButtonIndex to an updated newActiveButtonIndex that is not a number', () => {
+ assert.equal(wrapper.state('activeButtonIndex'), 1)
+ wrapper.setProps({ newActiveButtonIndex: null })
+ assert.equal(wrapper.state('activeButtonIndex'), 1)
+ })
+ })
+
describe('handleButtonClick', () => {
it('should set the activeButtonIndex', () => {
assert.equal(wrapper.state('activeButtonIndex'), 1)
diff --git a/ui/app/components/button/button.component.js b/ui/app/components/button/button.component.js
index 4a06333e7..5c617585d 100644
--- a/ui/app/components/button/button.component.js
+++ b/ui/app/components/button/button.component.js
@@ -22,7 +22,11 @@ export default class Button extends Component {
type: PropTypes.string,
large: PropTypes.bool,
className: PropTypes.string,
- children: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ children: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.array,
+ PropTypes.element,
+ ]),
}
render () {
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js
new file mode 100644
index 000000000..7c3142d0d
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js
@@ -0,0 +1,207 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import classnames from 'classnames'
+import Loading from '../../../loading-screen'
+import GasPriceChart from '../../gas-price-chart'
+import debounce from 'lodash.debounce'
+
+export default class AdvancedTabContent extends Component {
+ static contextTypes = {
+ t: PropTypes.func,
+ }
+
+ static propTypes = {
+ updateCustomGasPrice: PropTypes.func,
+ updateCustomGasLimit: PropTypes.func,
+ customGasPrice: PropTypes.number,
+ customGasLimit: PropTypes.number,
+ gasEstimatesLoading: PropTypes.bool,
+ millisecondsRemaining: PropTypes.number,
+ totalFee: PropTypes.string,
+ timeRemaining: PropTypes.string,
+ gasChartProps: PropTypes.object,
+ insufficientBalance: PropTypes.bool,
+ customPriceIsSafe: PropTypes.bool,
+ isSpeedUp: PropTypes.bool,
+ }
+
+ constructor (props) {
+ super(props)
+
+ this.debouncedGasLimitReset = debounce((dVal) => {
+ if (dVal < 21000) {
+ props.updateCustomGasLimit(21000)
+ }
+ }, 1000, { trailing: true })
+ this.onChangeGasLimit = (val) => {
+ props.updateCustomGasLimit(val)
+ this.debouncedGasLimitReset(val)
+ }
+ }
+
+ gasInputError ({ labelKey, insufficientBalance, customPriceIsSafe, isSpeedUp, value }) {
+ let errorText
+ let errorType
+ let isInError = true
+
+
+ if (insufficientBalance) {
+ errorText = 'Insufficient Balance'
+ errorType = 'error'
+ } else if (labelKey === 'gasPrice' && isSpeedUp && value === 0) {
+ errorText = 'Zero gas price on speed up'
+ errorType = 'error'
+ } else if (labelKey === 'gasPrice' && !customPriceIsSafe) {
+ errorText = 'Gas Price Extremely Low'
+ errorType = 'warning'
+ } else {
+ isInError = false
+ }
+
+ return {
+ isInError,
+ errorText,
+ errorType,
+ }
+ }
+
+ gasInput ({ labelKey, value, onChange, insufficientBalance, showGWEI, customPriceIsSafe, isSpeedUp }) {
+ const {
+ isInError,
+ errorText,
+ errorType,
+ } = this.gasInputError({ labelKey, insufficientBalance, customPriceIsSafe, isSpeedUp, value })
+
+ return (
+ <div className="advanced-tab__gas-edit-row__input-wrapper">
+ <input
+ className={classnames('advanced-tab__gas-edit-row__input', {
+ 'advanced-tab__gas-edit-row__input--error': isInError && errorType === 'error',
+ 'advanced-tab__gas-edit-row__input--warning': isInError && errorType === 'warning',
+ })}
+ type="number"
+ value={value}
+ onChange={event => onChange(Number(event.target.value))}
+ />
+ <div className={classnames('advanced-tab__gas-edit-row__input-arrows', {
+ 'advanced-tab__gas-edit-row__input--error': isInError && errorType === 'error',
+ 'advanced-tab__gas-edit-row__input--warning': isInError && errorType === 'warning',
+ })}>
+ <div className="advanced-tab__gas-edit-row__input-arrows__i-wrap" onClick={() => onChange(value + 1)}><i className="fa fa-sm fa-angle-up" /></div>
+ <div className="advanced-tab__gas-edit-row__input-arrows__i-wrap" onClick={() => onChange(value - 1)}><i className="fa fa-sm fa-angle-down" /></div>
+ </div>
+ { isInError
+ ? <div className={`advanced-tab__gas-edit-row__${errorType}-text`}>
+ { errorText }
+ </div>
+ : null }
+ </div>
+ )
+ }
+
+ infoButton (onClick) {
+ return <i className="fa fa-info-circle" onClick={onClick} />
+ }
+
+ renderDataSummary (totalFee, timeRemaining) {
+ return (
+ <div className="advanced-tab__transaction-data-summary">
+ <div className="advanced-tab__transaction-data-summary__titles">
+ <span>{ this.context.t('newTransactionFee') }</span>
+ <span>~{ this.context.t('transactionTime') }</span>
+ </div>
+ <div className="advanced-tab__transaction-data-summary__container">
+ <div className="advanced-tab__transaction-data-summary__fee">
+ {totalFee}
+ </div>
+ <div className="time-remaining">{timeRemaining}</div>
+ </div>
+ </div>
+ )
+ }
+
+ renderGasEditRow (gasInputArgs) {
+ return (
+ <div className="advanced-tab__gas-edit-row">
+ <div className="advanced-tab__gas-edit-row__label">
+ { this.context.t(gasInputArgs.labelKey) }
+ { this.infoButton(() => {}) }
+ </div>
+ { this.gasInput(gasInputArgs) }
+ </div>
+ )
+ }
+
+ renderGasEditRows ({
+ customGasPrice,
+ updateCustomGasPrice,
+ customGasLimit,
+ updateCustomGasLimit,
+ insufficientBalance,
+ customPriceIsSafe,
+ isSpeedUp,
+ }) {
+ return (
+ <div className="advanced-tab__gas-edit-rows">
+ { this.renderGasEditRow({
+ labelKey: 'gasPrice',
+ value: customGasPrice,
+ onChange: updateCustomGasPrice,
+ insufficientBalance,
+ customPriceIsSafe,
+ showGWEI: true,
+ isSpeedUp,
+ }) }
+ { this.renderGasEditRow({
+ labelKey: 'gasLimit',
+ value: customGasLimit,
+ onChange: this.onChangeGasLimit,
+ insufficientBalance,
+ customPriceIsSafe,
+ }) }
+ </div>
+ )
+ }
+
+ render () {
+ const {
+ updateCustomGasPrice,
+ updateCustomGasLimit,
+ timeRemaining,
+ customGasPrice,
+ customGasLimit,
+ insufficientBalance,
+ totalFee,
+ gasChartProps,
+ gasEstimatesLoading,
+ customPriceIsSafe,
+ isSpeedUp,
+ } = this.props
+
+ return (
+ <div className="advanced-tab">
+ { this.renderDataSummary(totalFee, timeRemaining) }
+ <div className="advanced-tab__fee-chart">
+ { this.renderGasEditRows({
+ customGasPrice,
+ updateCustomGasPrice,
+ customGasLimit,
+ updateCustomGasLimit,
+ insufficientBalance,
+ customPriceIsSafe,
+ isSpeedUp,
+ }) }
+ <div className="advanced-tab__fee-chart__title">Live Gas Price Predictions</div>
+ {!gasEstimatesLoading
+ ? <GasPriceChart {...gasChartProps} updateCustomGasPrice={updateCustomGasPrice} />
+ : <Loading />
+ }
+ <div className="advanced-tab__fee-chart__speed-buttons">
+ <span>Slower</span>
+ <span>Faster</span>
+ </div>
+ </div>
+ </div>
+ )
+ }
+}
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/index.js b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/index.js
new file mode 100644
index 000000000..492037f25
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/index.js
@@ -0,0 +1 @@
+export { default } from './advanced-tab-content.component'
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/index.scss b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/index.scss
new file mode 100644
index 000000000..53cb84791
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/index.scss
@@ -0,0 +1,203 @@
+@import './time-remaining/index';
+
+.advanced-tab {
+ display: flex;
+ flex-flow: column;
+
+ &__transaction-data-summary,
+ &__fee-chart-title {
+ padding-left: 24px;
+ padding-right: 24px;
+ }
+
+ &__transaction-data-summary {
+ display: flex;
+ flex-flow: column;
+ color: $mid-gray;
+ margin-top: 12px;
+ padding-left: 18px;
+ padding-right: 18px;
+
+ &__titles,
+ &__container {
+ display: flex;
+ flex-flow: row;
+ justify-content: space-between;
+ font-size: 12px;
+ color: #888EA3;
+ }
+
+ &__container {
+ font-size: 16px;
+ margin-top: 0px;
+ }
+
+ &__fee {
+ font-size: 16px;
+ color: #313A5E;
+ }
+ }
+
+ &__fee-chart {
+ margin-top: 8px;
+ height: 265px;
+ background: #F8F9FB;
+ border-bottom: 1px solid #d2d8dd;
+ border-top: 1px solid #d2d8dd;
+ position: relative;
+
+ &__title {
+ font-size: 12px;
+ color: #313A5E;
+ margin-left: 22px;
+ }
+
+ &__speed-buttons {
+ position: absolute;
+ bottom: 13px;
+ display: flex;
+ justify-content: space-between;
+ padding-left: 20px;
+ padding-right: 19px;
+ width: 100%;
+ font-size: 10px;
+ color: #888EA3;
+ }
+ }
+
+ &__slider-container {
+ padding-left: 27px;
+ padding-right: 27px;
+ }
+
+ &__gas-edit-rows {
+ height: 73px;
+ display: flex;
+ flex-flow: row;
+ justify-content: space-between;
+ margin-left: 20px;
+ margin-right: 10px;
+ margin-top: 9px;
+ }
+
+ &__gas-edit-row {
+ display: flex;
+ flex-flow: column;
+
+ &__label {
+ color: #313B5E;
+ font-size: 14px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ .fa-info-circle {
+ color: $silver;
+ margin-left: 10px;
+ cursor: pointer;
+ }
+
+ .fa-info-circle:hover {
+ color: $mid-gray;
+ }
+ }
+
+ &__error-text {
+ font-size: 12px;
+ color: red;
+ }
+
+ &__warning-text {
+ font-size: 12px;
+ color: orange;
+ }
+
+ &__input-wrapper {
+ position: relative;
+ }
+
+ &__input {
+ border: 1px solid $dusty-gray;
+ border-radius: 4px;
+ color: $mid-gray;
+ font-size: 16px;
+ height: 24px;
+ width: 155px;
+ padding-left: 8px;
+ padding-top: 2px;
+ margin-top: 7px;
+ }
+
+ &__input--error {
+ border: 1px solid $red;
+ }
+
+ &__input--warning {
+ border: 1px solid $orange;
+ }
+
+ &__input-arrows {
+ position: absolute;
+ top: 7px;
+ right: 0px;
+ width: 17px;
+ height: 24px;
+ border: 1px solid #dadada;
+ border-top-right-radius: 4px;
+ display: flex;
+ flex-direction: column;
+ color: #9b9b9b;
+ font-size: .8em;
+ border-bottom-right-radius: 4px;
+ cursor: pointer;
+
+ &__i-wrap {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ cursor: pointer;
+ }
+
+ &__i-wrap:hover {
+ background: #4EADE7;
+ color: $white;
+ }
+
+ i:hover {
+ background: #4EADE7;
+ }
+
+ i {
+ font-size: 10px;
+ }
+ }
+
+ &__input-arrows--error {
+ border: 1px solid $red;
+ }
+
+ &__input-arrows--warning {
+ border: 1px solid $orange;
+ }
+
+ input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ display: none;
+ }
+
+ input[type="number"]:hover::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ display: none;
+ }
+
+ &__gwei-symbol {
+ position: absolute;
+ top: 8px;
+ right: 10px;
+ color: $dusty-gray;
+ }
+ }
+} \ No newline at end of file
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/tests/advanced-tab-content-component.test.js b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/tests/advanced-tab-content-component.test.js
new file mode 100644
index 000000000..00242e430
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/tests/advanced-tab-content-component.test.js
@@ -0,0 +1,364 @@
+import React from 'react'
+import assert from 'assert'
+import shallow from '../../../../../../lib/shallow-with-context'
+import sinon from 'sinon'
+import AdvancedTabContent from '../advanced-tab-content.component.js'
+
+import GasPriceChart from '../../../gas-price-chart'
+import Loading from '../../../../loading-screen'
+
+const propsMethodSpies = {
+ updateCustomGasPrice: sinon.spy(),
+ updateCustomGasLimit: sinon.spy(),
+}
+
+sinon.spy(AdvancedTabContent.prototype, 'renderGasEditRow')
+sinon.spy(AdvancedTabContent.prototype, 'gasInput')
+sinon.spy(AdvancedTabContent.prototype, 'renderGasEditRows')
+sinon.spy(AdvancedTabContent.prototype, 'renderDataSummary')
+sinon.spy(AdvancedTabContent.prototype, 'gasInputError')
+
+describe('AdvancedTabContent Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<AdvancedTabContent
+ updateCustomGasPrice={propsMethodSpies.updateCustomGasPrice}
+ updateCustomGasLimit={propsMethodSpies.updateCustomGasLimit}
+ customGasPrice={11}
+ customGasLimit={23456}
+ timeRemaining={21500}
+ totalFee={'$0.25'}
+ insufficientBalance={false}
+ customPriceIsSafe={true}
+ isSpeedUp={false}
+ />, { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } })
+ })
+
+ afterEach(() => {
+ propsMethodSpies.updateCustomGasPrice.resetHistory()
+ propsMethodSpies.updateCustomGasLimit.resetHistory()
+ AdvancedTabContent.prototype.renderGasEditRow.resetHistory()
+ AdvancedTabContent.prototype.gasInput.resetHistory()
+ AdvancedTabContent.prototype.renderGasEditRows.resetHistory()
+ AdvancedTabContent.prototype.renderDataSummary.resetHistory()
+ })
+
+ describe('render()', () => {
+ it('should render the advanced-tab root node', () => {
+ assert(wrapper.hasClass('advanced-tab'))
+ })
+
+ it('should render the expected four children of the advanced-tab div', () => {
+ const advancedTabChildren = wrapper.children()
+ assert.equal(advancedTabChildren.length, 2)
+
+ assert(advancedTabChildren.at(0).hasClass('advanced-tab__transaction-data-summary'))
+ assert(advancedTabChildren.at(1).hasClass('advanced-tab__fee-chart'))
+
+ const feeChartDiv = advancedTabChildren.at(1)
+
+ assert(feeChartDiv.childAt(0).hasClass('advanced-tab__gas-edit-rows'))
+ assert(feeChartDiv.childAt(1).hasClass('advanced-tab__fee-chart__title'))
+ assert(feeChartDiv.childAt(2).is(GasPriceChart))
+ assert(feeChartDiv.childAt(3).hasClass('advanced-tab__fee-chart__speed-buttons'))
+ })
+
+ it('should render a loading component instead of the chart if gasEstimatesLoading is true', () => {
+ wrapper.setProps({ gasEstimatesLoading: true })
+ const advancedTabChildren = wrapper.children()
+ assert.equal(advancedTabChildren.length, 2)
+
+ assert(advancedTabChildren.at(0).hasClass('advanced-tab__transaction-data-summary'))
+ assert(advancedTabChildren.at(1).hasClass('advanced-tab__fee-chart'))
+
+ const feeChartDiv = advancedTabChildren.at(1)
+
+ assert(feeChartDiv.childAt(0).hasClass('advanced-tab__gas-edit-rows'))
+ assert(feeChartDiv.childAt(1).hasClass('advanced-tab__fee-chart__title'))
+ assert(feeChartDiv.childAt(2).is(Loading))
+ assert(feeChartDiv.childAt(3).hasClass('advanced-tab__fee-chart__speed-buttons'))
+ })
+
+ it('should call renderDataSummary with the expected params', () => {
+ assert.equal(AdvancedTabContent.prototype.renderGasEditRows.callCount, 1)
+ const renderDataSummaryArgs = AdvancedTabContent.prototype.renderDataSummary.getCall(0).args
+ assert.deepEqual(renderDataSummaryArgs, ['$0.25', 21500])
+ })
+
+ it('should call renderGasEditRows with the expected params', () => {
+ assert.equal(AdvancedTabContent.prototype.renderGasEditRows.callCount, 1)
+ const renderGasEditRowArgs = AdvancedTabContent.prototype.renderGasEditRows.getCall(0).args
+ assert.deepEqual(renderGasEditRowArgs, [{
+ customGasPrice: 11,
+ customGasLimit: 23456,
+ insufficientBalance: false,
+ customPriceIsSafe: true,
+ updateCustomGasPrice: propsMethodSpies.updateCustomGasPrice,
+ updateCustomGasLimit: propsMethodSpies.updateCustomGasLimit,
+ isSpeedUp: false,
+ }])
+ })
+ })
+
+ describe('renderDataSummary()', () => {
+ let dataSummary
+
+ beforeEach(() => {
+ dataSummary = shallow(wrapper.instance().renderDataSummary('mockTotalFee', 'mockMsRemaining'))
+ })
+
+ it('should render the transaction-data-summary root node', () => {
+ assert(dataSummary.hasClass('advanced-tab__transaction-data-summary'))
+ })
+
+ it('should render titles of the data', () => {
+ const titlesNode = dataSummary.children().at(0)
+ assert(titlesNode.hasClass('advanced-tab__transaction-data-summary__titles'))
+ assert.equal(titlesNode.children().at(0).text(), 'newTransactionFee')
+ assert.equal(titlesNode.children().at(1).text(), '~transactionTime')
+ })
+
+ it('should render the data', () => {
+ const dataNode = dataSummary.children().at(1)
+ assert(dataNode.hasClass('advanced-tab__transaction-data-summary__container'))
+ assert.equal(dataNode.children().at(0).text(), 'mockTotalFee')
+ assert(dataNode.children().at(1).hasClass('time-remaining'))
+ assert.equal(dataNode.children().at(1).text(), 'mockMsRemaining')
+ })
+ })
+
+ describe('renderGasEditRow()', () => {
+ let gasEditRow
+
+ beforeEach(() => {
+ AdvancedTabContent.prototype.gasInput.resetHistory()
+ gasEditRow = shallow(wrapper.instance().renderGasEditRow({
+ labelKey: 'mockLabelKey',
+ someArg: 'argA',
+ }))
+ })
+
+ it('should render the gas-edit-row root node', () => {
+ assert(gasEditRow.hasClass('advanced-tab__gas-edit-row'))
+ })
+
+ it('should render a label and an input', () => {
+ const gasEditRowChildren = gasEditRow.children()
+ assert.equal(gasEditRowChildren.length, 2)
+ assert(gasEditRowChildren.at(0).hasClass('advanced-tab__gas-edit-row__label'))
+ assert(gasEditRowChildren.at(1).hasClass('advanced-tab__gas-edit-row__input-wrapper'))
+ })
+
+ it('should render the label key and info button', () => {
+ const gasRowLabelChildren = gasEditRow.children().at(0).children()
+ assert.equal(gasRowLabelChildren.length, 2)
+ assert(gasRowLabelChildren.at(0), 'mockLabelKey')
+ assert(gasRowLabelChildren.at(1).hasClass('fa-info-circle'))
+ })
+
+ it('should call this.gasInput with the correct args', () => {
+ const gasInputSpyArgs = AdvancedTabContent.prototype.gasInput.args
+ assert.deepEqual(gasInputSpyArgs[0], [ { labelKey: 'mockLabelKey', someArg: 'argA' } ])
+ })
+ })
+
+ describe('renderGasEditRows()', () => {
+ let gasEditRows
+ let tempOnChangeGasLimit
+
+ beforeEach(() => {
+ tempOnChangeGasLimit = wrapper.instance().onChangeGasLimit
+ wrapper.instance().onChangeGasLimit = () => 'mockOnChangeGasLimit'
+ AdvancedTabContent.prototype.renderGasEditRow.resetHistory()
+ gasEditRows = shallow(wrapper.instance().renderGasEditRows(
+ 'mockGasPrice',
+ () => 'mockUpdateCustomGasPriceReturn',
+ 'mockGasLimit',
+ () => 'mockUpdateCustomGasLimitReturn',
+ false
+ ))
+ })
+
+ afterEach(() => {
+ wrapper.instance().onChangeGasLimit = tempOnChangeGasLimit
+ })
+
+ it('should render the gas-edit-rows root node', () => {
+ assert(gasEditRows.hasClass('advanced-tab__gas-edit-rows'))
+ })
+
+ it('should render two rows', () => {
+ const gasEditRowsChildren = gasEditRows.children()
+ assert.equal(gasEditRowsChildren.length, 2)
+ assert(gasEditRowsChildren.at(0).hasClass('advanced-tab__gas-edit-row'))
+ assert(gasEditRowsChildren.at(1).hasClass('advanced-tab__gas-edit-row'))
+ })
+
+ it('should call this.renderGasEditRow twice, with the expected args', () => {
+ const renderGasEditRowSpyArgs = AdvancedTabContent.prototype.renderGasEditRow.args
+ assert.equal(renderGasEditRowSpyArgs.length, 2)
+ assert.deepEqual(renderGasEditRowSpyArgs[0].map(String), [{
+ labelKey: 'gasPrice',
+ value: 'mockGasLimit',
+ onChange: () => 'mockOnChangeGasLimit',
+ insufficientBalance: false,
+ customPriceIsSafe: true,
+ showGWEI: true,
+ }].map(String))
+ assert.deepEqual(renderGasEditRowSpyArgs[1].map(String), [{
+ labelKey: 'gasPrice',
+ value: 'mockGasPrice',
+ onChange: () => 'mockUpdateCustomGasPriceReturn',
+ insufficientBalance: false,
+ customPriceIsSafe: true,
+ showGWEI: true,
+ }].map(String))
+ })
+ })
+
+ describe('infoButton()', () => {
+ let infoButton
+
+ beforeEach(() => {
+ AdvancedTabContent.prototype.renderGasEditRow.resetHistory()
+ infoButton = shallow(wrapper.instance().infoButton(() => 'mockOnClickReturn'))
+ })
+
+ it('should render the i element', () => {
+ assert(infoButton.hasClass('fa-info-circle'))
+ })
+
+ it('should pass the onClick argument to the i tag onClick prop', () => {
+ assert(infoButton.props().onClick(), 'mockOnClickReturn')
+ })
+ })
+
+ describe('gasInput()', () => {
+ let gasInput
+
+ beforeEach(() => {
+ AdvancedTabContent.prototype.renderGasEditRow.resetHistory()
+ AdvancedTabContent.prototype.gasInputError.resetHistory()
+ gasInput = shallow(wrapper.instance().gasInput({
+ labelKey: 'gasPrice',
+ value: 321,
+ onChange: value => value + 7,
+ insufficientBalance: false,
+ showGWEI: true,
+ customPriceIsSafe: true,
+ isSpeedUp: false,
+ }))
+ })
+
+ it('should render the input-wrapper root node', () => {
+ assert(gasInput.hasClass('advanced-tab__gas-edit-row__input-wrapper'))
+ })
+
+ it('should render two children, including an input', () => {
+ assert.equal(gasInput.children().length, 2)
+ assert(gasInput.children().at(0).hasClass('advanced-tab__gas-edit-row__input'))
+ })
+
+ it('should call the passed onChange method with the value of the input onChange event', () => {
+ const inputOnChange = gasInput.find('input').props().onChange
+ assert.equal(inputOnChange({ target: { value: 8} }), 15)
+ })
+
+ it('should have two input arrows', () => {
+ const upArrow = gasInput.find('.fa-angle-up')
+ assert.equal(upArrow.length, 1)
+ const downArrow = gasInput.find('.fa-angle-down')
+ assert.equal(downArrow.length, 1)
+ })
+
+ it('should call onChange with the value incremented decremented when its onchange method is called', () => {
+ const upArrow = gasInput.find('.advanced-tab__gas-edit-row__input-arrows__i-wrap').at(0)
+ assert.equal(upArrow.props().onClick(), 329)
+ const downArrow = gasInput.find('.advanced-tab__gas-edit-row__input-arrows__i-wrap').at(1)
+ assert.equal(downArrow.props().onClick(), 327)
+ })
+
+ it('should call gasInputError with the expected params', () => {
+ assert.equal(AdvancedTabContent.prototype.gasInputError.callCount, 1)
+ const gasInputErrorArgs = AdvancedTabContent.prototype.gasInputError.getCall(0).args
+ assert.deepEqual(gasInputErrorArgs, [{
+ labelKey: 'gasPrice',
+ insufficientBalance: false,
+ customPriceIsSafe: true,
+ value: 321,
+ isSpeedUp: false,
+ }])
+ })
+ })
+
+ describe('gasInputError()', () => {
+ let gasInputError
+
+ beforeEach(() => {
+ AdvancedTabContent.prototype.renderGasEditRow.resetHistory()
+ gasInputError = wrapper.instance().gasInputError({
+ labelKey: '',
+ insufficientBalance: false,
+ customPriceIsSafe: true,
+ isSpeedUp: false,
+ })
+ })
+
+ it('should return an insufficientBalance error', () => {
+ const gasInputError = wrapper.instance().gasInputError({
+ labelKey: 'gasPrice',
+ insufficientBalance: true,
+ customPriceIsSafe: true,
+ isSpeedUp: false,
+ value: 1,
+ })
+ assert.deepEqual(gasInputError, {
+ isInError: true,
+ errorText: 'Insufficient Balance',
+ errorType: 'error',
+ })
+ })
+
+ it('should return a zero gas on retry error', () => {
+ const gasInputError = wrapper.instance().gasInputError({
+ labelKey: 'gasPrice',
+ insufficientBalance: false,
+ customPriceIsSafe: false,
+ isSpeedUp: true,
+ value: 0,
+ })
+ assert.deepEqual(gasInputError, {
+ isInError: true,
+ errorText: 'Zero gas price on speed up',
+ errorType: 'error',
+ })
+ })
+
+ it('should return a low gas warning', () => {
+ const gasInputError = wrapper.instance().gasInputError({
+ labelKey: 'gasPrice',
+ insufficientBalance: false,
+ customPriceIsSafe: false,
+ isSpeedUp: false,
+ value: 1,
+ })
+ assert.deepEqual(gasInputError, {
+ isInError: true,
+ errorText: 'Gas Price Extremely Low',
+ errorType: 'warning',
+ })
+ })
+
+ it('should return isInError false if there is no error', () => {
+ gasInputError = wrapper.instance().gasInputError({
+ labelKey: 'gasPrice',
+ insufficientBalance: false,
+ customPriceIsSafe: true,
+ value: 1,
+ })
+ assert.equal(gasInputError.isInError, false)
+ })
+ })
+
+})
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.js b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.js
new file mode 100644
index 000000000..61b681e1a
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.js
@@ -0,0 +1 @@
+export { default } from './time-remaining.component'
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.scss b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.scss
new file mode 100644
index 000000000..e2115af7f
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/index.scss
@@ -0,0 +1,17 @@
+.time-remaining {
+ color: #313A5E;
+ font-size: 16px;
+
+ .minutes-num, .seconds-num {
+ font-size: 16px;
+ }
+
+ .seconds-num {
+ margin-left: 7px;
+ font-size: 16px;
+ }
+
+ .minutes-label, .seconds-label {
+ font-size: 16px;
+ }
+} \ No newline at end of file
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/tests/time-remaining-component.test.js b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/tests/time-remaining-component.test.js
new file mode 100644
index 000000000..d8490272f
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/tests/time-remaining-component.test.js
@@ -0,0 +1,30 @@
+import React from 'react'
+import assert from 'assert'
+import shallow from '../../../../../../../lib/shallow-with-context'
+import TimeRemaining from '../time-remaining.component.js'
+
+describe('TimeRemaining Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<TimeRemaining
+ milliseconds={495000}
+ />)
+ })
+
+ describe('render()', () => {
+ it('should render the time-remaining root node', () => {
+ assert(wrapper.hasClass('time-remaining'))
+ })
+
+ it('should render minutes and seconds numbers and labels', () => {
+ const timeRemainingChildren = wrapper.children()
+ assert.equal(timeRemainingChildren.length, 4)
+ assert.equal(timeRemainingChildren.at(0).text(), 8)
+ assert.equal(timeRemainingChildren.at(1).text(), 'minutesShorthand')
+ assert.equal(timeRemainingChildren.at(2).text(), 15)
+ assert.equal(timeRemainingChildren.at(3).text(), 'secondsShorthand')
+ })
+ })
+
+})
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.component.js b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.component.js
new file mode 100644
index 000000000..826d41f9c
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.component.js
@@ -0,0 +1,33 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import { getTimeBreakdown } from './time-remaining.utils'
+
+export default class TimeRemaining extends Component {
+ static contextTypes = {
+ t: PropTypes.func,
+ }
+
+ static propTypes = {
+ milliseconds: PropTypes.number,
+ }
+
+ render () {
+ const {
+ milliseconds,
+ } = this.props
+
+ const {
+ minutes,
+ seconds,
+ } = getTimeBreakdown(milliseconds)
+
+ return (
+ <div className="time-remaining">
+ <span className="minutes-num">{minutes}</span>
+ <span className="minutes-label">{this.context.t('minutesShorthand')}</span>
+ <span className="seconds-num">{seconds}</span>
+ <span className="seconds-label">{this.context.t('secondsShorthand')}</span>
+ </div>
+ )
+ }
+}
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.utils.js b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.utils.js
new file mode 100644
index 000000000..cf43e0acb
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/advanced-tab-content/time-remaining/time-remaining.utils.js
@@ -0,0 +1,11 @@
+function getTimeBreakdown (milliseconds) {
+ return {
+ hours: Math.floor(milliseconds / 3600000),
+ minutes: Math.floor((milliseconds % 3600000) / 60000),
+ seconds: Math.floor((milliseconds % 60000) / 1000),
+ }
+}
+
+module.exports = {
+ getTimeBreakdown,
+}
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/basic-tab-content.component.js b/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/basic-tab-content.component.js
new file mode 100644
index 000000000..264d038da
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/basic-tab-content.component.js
@@ -0,0 +1,34 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import Loading from '../../../loading-screen'
+import GasPriceButtonGroup from '../../gas-price-button-group'
+
+export default class BasicTabContent extends Component {
+ static contextTypes = {
+ t: PropTypes.func,
+ }
+
+ static propTypes = {
+ gasPriceButtonGroupProps: PropTypes.object,
+ }
+
+ render () {
+ const { gasPriceButtonGroupProps } = this.props
+
+ return (
+ <div className="basic-tab-content">
+ <div className="basic-tab-content__title">Estimated Processing Times</div>
+ <div className="basic-tab-content__blurb">Select a higher gas fee to accelerate the processing of your transaction.*</div>
+ {!gasPriceButtonGroupProps.loading
+ ? <GasPriceButtonGroup
+ className="gas-price-button-group--alt"
+ showCheck={true}
+ {...gasPriceButtonGroupProps}
+ />
+ : <Loading />
+ }
+ <div className="basic-tab-content__footer-blurb">* Accelerating a transaction by using a higher gas price increases its chances of getting processed by the network faster, but it is not always guaranteed.</div>
+ </div>
+ )
+ }
+}
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/index.js b/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/index.js
new file mode 100644
index 000000000..078d50fce
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/index.js
@@ -0,0 +1 @@
+export { default } from './basic-tab-content.component'
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/index.scss b/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/index.scss
new file mode 100644
index 000000000..e34e4e328
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/index.scss
@@ -0,0 +1,28 @@
+.basic-tab-content {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ padding-left: 21px;
+ height: 324px;
+ background: #F5F7F8;
+ border-bottom: 1px solid #d2d8dd;
+
+ &__title {
+ margin-top: 19px;
+ font-size: 16px;
+ color: $black;
+ }
+
+ &__blurb {
+ font-size: 12px;
+ color: $black;
+ margin-top: 5px;
+ margin-bottom: 15px;
+ }
+
+ &__footer-blurb {
+ font-size: 12px;
+ color: #979797;
+ margin-top: 15px;
+ }
+}
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/tests/basic-tab-content-component.test.js b/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/tests/basic-tab-content-component.test.js
new file mode 100644
index 000000000..25abdd997
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/basic-tab-content/tests/basic-tab-content-component.test.js
@@ -0,0 +1,82 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import BasicTabContent from '../basic-tab-content.component'
+
+import GasPriceButtonGroup from '../../../gas-price-button-group/'
+import Loading from '../../../../loading-screen'
+
+const mockGasPriceButtonGroupProps = {
+ buttonDataLoading: false,
+ className: 'gas-price-button-group',
+ gasButtonInfo: [
+ {
+ feeInPrimaryCurrency: '$0.52',
+ feeInSecondaryCurrency: '0.0048 ETH',
+ timeEstimate: '~ 1 min 0 sec',
+ priceInHexWei: '0xa1b2c3f',
+ },
+ {
+ feeInPrimaryCurrency: '$0.39',
+ feeInSecondaryCurrency: '0.004 ETH',
+ timeEstimate: '~ 1 min 30 sec',
+ priceInHexWei: '0xa1b2c39',
+ },
+ {
+ feeInPrimaryCurrency: '$0.30',
+ feeInSecondaryCurrency: '0.00354 ETH',
+ timeEstimate: '~ 2 min 1 sec',
+ priceInHexWei: '0xa1b2c30',
+ },
+ ],
+ handleGasPriceSelection: newPrice => console.log('NewPrice: ', newPrice),
+ noButtonActiveByDefault: true,
+ showCheck: true,
+}
+
+describe('BasicTabContent Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<BasicTabContent
+ gasPriceButtonGroupProps={mockGasPriceButtonGroupProps}
+ />)
+ })
+
+ describe('render', () => {
+ it('should have a title', () => {
+ assert(wrapper.find('.basic-tab-content').childAt(0).hasClass('basic-tab-content__title'))
+ })
+
+ it('should render a GasPriceButtonGroup compenent', () => {
+ assert.equal(wrapper.find(GasPriceButtonGroup).length, 1)
+ })
+
+ it('should pass correct props to GasPriceButtonGroup', () => {
+ const {
+ buttonDataLoading,
+ className,
+ gasButtonInfo,
+ handleGasPriceSelection,
+ noButtonActiveByDefault,
+ showCheck,
+ } = wrapper.find(GasPriceButtonGroup).props()
+ assert.equal(wrapper.find(GasPriceButtonGroup).length, 1)
+ assert.equal(buttonDataLoading, mockGasPriceButtonGroupProps.buttonDataLoading)
+ assert.equal(className, mockGasPriceButtonGroupProps.className)
+ assert.equal(noButtonActiveByDefault, mockGasPriceButtonGroupProps.noButtonActiveByDefault)
+ assert.equal(showCheck, mockGasPriceButtonGroupProps.showCheck)
+ assert.deepEqual(gasButtonInfo, mockGasPriceButtonGroupProps.gasButtonInfo)
+ assert.equal(JSON.stringify(handleGasPriceSelection), JSON.stringify(mockGasPriceButtonGroupProps.handleGasPriceSelection))
+ })
+
+ it('should render a loading component instead of the GasPriceButtonGroup if gasPriceButtonGroupProps.loading is true', () => {
+ wrapper.setProps({
+ gasPriceButtonGroupProps: { ...mockGasPriceButtonGroupProps, loading: true },
+ })
+
+ assert.equal(wrapper.find(GasPriceButtonGroup).length, 0)
+ assert.equal(wrapper.find(Loading).length, 1)
+ })
+ })
+})
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js b/ui/app/components/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js
new file mode 100644
index 000000000..64c2be353
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js
@@ -0,0 +1,186 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import PageContainer from '../../page-container'
+import { Tabs, Tab } from '../../tabs'
+import AdvancedTabContent from './advanced-tab-content'
+import BasicTabContent from './basic-tab-content'
+
+export default class GasModalPageContainer extends Component {
+ static contextTypes = {
+ t: PropTypes.func,
+ }
+
+ static propTypes = {
+ hideModal: PropTypes.func,
+ hideBasic: PropTypes.bool,
+ updateCustomGasPrice: PropTypes.func,
+ updateCustomGasLimit: PropTypes.func,
+ customGasPrice: PropTypes.number,
+ customGasLimit: PropTypes.number,
+ fetchBasicGasAndTimeEstimates: PropTypes.func,
+ fetchGasEstimates: PropTypes.func,
+ gasPriceButtonGroupProps: PropTypes.object,
+ infoRowProps: PropTypes.shape({
+ originalTotalFiat: PropTypes.string,
+ originalTotalEth: PropTypes.string,
+ newTotalFiat: PropTypes.string,
+ newTotalEth: PropTypes.string,
+ }),
+ onSubmit: PropTypes.func,
+ customModalGasPriceInHex: PropTypes.string,
+ customModalGasLimitInHex: PropTypes.string,
+ cancelAndClose: PropTypes.func,
+ transactionFee: PropTypes.string,
+ blockTime: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number,
+ ]),
+ customPriceIsSafe: PropTypes.bool,
+ isSpeedUp: PropTypes.bool,
+ disableSave: PropTypes.bool,
+ }
+
+ state = {}
+
+ componentDidMount () {
+ const promise = this.props.hideBasic
+ ? Promise.resolve(this.props.blockTime)
+ : this.props.fetchBasicGasAndTimeEstimates()
+ .then(basicEstimates => basicEstimates.blockTime)
+
+ promise
+ .then(blockTime => {
+ this.props.fetchGasEstimates(blockTime)
+ })
+ }
+
+ renderBasicTabContent (gasPriceButtonGroupProps) {
+ return (
+ <BasicTabContent
+ gasPriceButtonGroupProps={gasPriceButtonGroupProps}
+ />
+ )
+ }
+
+ renderAdvancedTabContent ({
+ convertThenUpdateCustomGasPrice,
+ convertThenUpdateCustomGasLimit,
+ customGasPrice,
+ customGasLimit,
+ newTotalFiat,
+ gasChartProps,
+ currentTimeEstimate,
+ insufficientBalance,
+ gasEstimatesLoading,
+ customPriceIsSafe,
+ isSpeedUp,
+ }) {
+ const { transactionFee } = this.props
+ return (
+ <AdvancedTabContent
+ updateCustomGasPrice={convertThenUpdateCustomGasPrice}
+ updateCustomGasLimit={convertThenUpdateCustomGasLimit}
+ customGasPrice={customGasPrice}
+ customGasLimit={customGasLimit}
+ timeRemaining={currentTimeEstimate}
+ transactionFee={transactionFee}
+ totalFee={newTotalFiat}
+ gasChartProps={gasChartProps}
+ insufficientBalance={insufficientBalance}
+ gasEstimatesLoading={gasEstimatesLoading}
+ customPriceIsSafe={customPriceIsSafe}
+ isSpeedUp={isSpeedUp}
+ />
+ )
+ }
+
+ renderInfoRows (newTotalFiat, newTotalEth, sendAmount, transactionFee) {
+ return (
+ <div className="gas-modal-content__info-row-wrapper">
+ <div className="gas-modal-content__info-row">
+ <div className="gas-modal-content__info-row__send-info">
+ <span className="gas-modal-content__info-row__send-info__label">{this.context.t('sendAmount')}</span>
+ <span className="gas-modal-content__info-row__send-info__value">{sendAmount}</span>
+ </div>
+ <div className="gas-modal-content__info-row__transaction-info">
+ <span className={'gas-modal-content__info-row__transaction-info__label'}>{this.context.t('transactionFee')}</span>
+ <span className="gas-modal-content__info-row__transaction-info__value">{transactionFee}</span>
+ </div>
+ <div className="gas-modal-content__info-row__total-info">
+ <span className="gas-modal-content__info-row__total-info__label">{this.context.t('newTotal')}</span>
+ <span className="gas-modal-content__info-row__total-info__value">{newTotalEth}</span>
+ </div>
+ <div className="gas-modal-content__info-row__fiat-total-info">
+ <span className="gas-modal-content__info-row__fiat-total-info__value">{newTotalFiat}</span>
+ </div>
+ </div>
+ </div>
+ )
+ }
+
+ renderTabs ({
+ originalTotalFiat,
+ originalTotalEth,
+ newTotalFiat,
+ newTotalEth,
+ sendAmount,
+ transactionFee,
+ },
+ {
+ gasPriceButtonGroupProps,
+ hideBasic,
+ ...advancedTabProps
+ }) {
+ let tabsToRender = [
+ { name: 'basic', content: this.renderBasicTabContent(gasPriceButtonGroupProps) },
+ { name: 'advanced', content: this.renderAdvancedTabContent(advancedTabProps) },
+ ]
+
+ if (hideBasic) {
+ tabsToRender = tabsToRender.slice(1)
+ }
+
+ return (
+ <Tabs>
+ {tabsToRender.map(({ name, content }, i) => <Tab name={this.context.t(name)} key={`gas-modal-tab-${i}`}>
+ <div className="gas-modal-content">
+ { content }
+ { this.renderInfoRows(newTotalFiat, newTotalEth, sendAmount, transactionFee) }
+ </div>
+ </Tab>
+ )}
+ </Tabs>
+ )
+ }
+
+ render () {
+ const {
+ cancelAndClose,
+ infoRowProps,
+ onSubmit,
+ customModalGasPriceInHex,
+ customModalGasLimitInHex,
+ disableSave,
+ ...tabProps
+ } = this.props
+
+ return (
+ <div className="gas-modal-page-container">
+ <PageContainer
+ title={this.context.t('customGas')}
+ subtitle={this.context.t('customGasSubTitle')}
+ tabsComponent={this.renderTabs(infoRowProps, tabProps)}
+ disabled={disableSave}
+ onCancel={() => cancelAndClose()}
+ onClose={() => cancelAndClose()}
+ onSubmit={() => {
+ onSubmit(customModalGasLimitInHex, customModalGasPriceInHex)
+ }}
+ submitText={this.context.t('save')}
+ headerCloseText={'Close'}
+ hideCancel={true}
+ />
+ </div>
+ )
+ }
+}
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js b/ui/app/components/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js
new file mode 100644
index 000000000..dde0f2b94
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js
@@ -0,0 +1,286 @@
+import { connect } from 'react-redux'
+import { pipe, partialRight } from 'ramda'
+import GasModalPageContainer from './gas-modal-page-container.component'
+import {
+ hideModal,
+ setGasLimit,
+ setGasPrice,
+ createSpeedUpTransaction,
+ hideSidebar,
+} from '../../../actions'
+import {
+ setCustomGasPrice,
+ setCustomGasLimit,
+ resetCustomData,
+ setCustomTimeEstimate,
+ fetchGasEstimates,
+ fetchBasicGasAndTimeEstimates,
+} from '../../../ducks/gas.duck'
+import {
+ hideGasButtonGroup,
+} from '../../../ducks/send.duck'
+import {
+ updateGasAndCalculate,
+} from '../../../ducks/confirm-transaction.duck'
+import {
+ getCurrentCurrency,
+ conversionRateSelector as getConversionRate,
+ getSelectedToken,
+ getCurrentEthBalance,
+} from '../../../selectors.js'
+import {
+ formatTimeEstimate,
+ getFastPriceEstimateInHexWEI,
+ getBasicGasEstimateLoadingStatus,
+ getGasEstimatesLoadingStatus,
+ getCustomGasLimit,
+ getCustomGasPrice,
+ getDefaultActiveButtonIndex,
+ getEstimatedGasPrices,
+ getEstimatedGasTimes,
+ getRenderableBasicEstimateData,
+ getBasicGasEstimateBlockTime,
+ isCustomPriceSafe,
+} from '../../../selectors/custom-gas'
+import {
+ submittedPendingTransactionsSelector,
+} from '../../../selectors/transactions'
+import {
+ formatCurrency,
+} from '../../../helpers/confirm-transaction/util'
+import {
+ addHexWEIsToDec,
+ decEthToConvertedCurrency as ethTotalToConvertedCurrency,
+ decGWEIToHexWEI,
+ hexWEIToDecGWEI,
+} from '../../../helpers/conversions.util'
+import {
+ formatETHFee,
+} from '../../../helpers/formatters'
+import {
+ calcGasTotal,
+ isBalanceSufficient,
+} from '../../send/send.utils'
+import { addHexPrefix } from 'ethereumjs-util'
+import { getAdjacentGasPrices, extrapolateY } from '../gas-price-chart/gas-price-chart.utils'
+
+const mapStateToProps = (state, ownProps) => {
+ const { transaction = {} } = ownProps
+ const buttonDataLoading = getBasicGasEstimateLoadingStatus(state)
+ const gasEstimatesLoading = getGasEstimatesLoadingStatus(state)
+
+ const { gasPrice: currentGasPrice, gas: currentGasLimit, value } = getTxParams(state, transaction.id)
+ const customModalGasPriceInHex = getCustomGasPrice(state) || currentGasPrice
+ const customModalGasLimitInHex = getCustomGasLimit(state) || currentGasLimit
+ const gasTotal = calcGasTotal(customModalGasLimitInHex, customModalGasPriceInHex)
+
+ const customGasTotal = calcGasTotal(customModalGasLimitInHex, customModalGasPriceInHex)
+
+ const gasButtonInfo = getRenderableBasicEstimateData(state)
+
+ const currentCurrency = getCurrentCurrency(state)
+ const conversionRate = getConversionRate(state)
+
+ const newTotalFiat = addHexWEIsToRenderableFiat(value, customGasTotal, currentCurrency, conversionRate)
+
+ const hideBasic = state.appState.modal.modalState.props.hideBasic
+
+ const customGasPrice = calcCustomGasPrice(customModalGasPriceInHex)
+
+ const gasPrices = getEstimatedGasPrices(state)
+ const estimatedTimes = getEstimatedGasTimes(state)
+ const balance = getCurrentEthBalance(state)
+
+ const insufficientBalance = !isBalanceSufficient({
+ amount: value,
+ gasTotal,
+ balance,
+ conversionRate,
+ })
+
+ return {
+ hideBasic,
+ isConfirm: isConfirm(state),
+ customModalGasPriceInHex,
+ customModalGasLimitInHex,
+ customGasPrice,
+ customGasLimit: calcCustomGasLimit(customModalGasLimitInHex),
+ newTotalFiat,
+ currentTimeEstimate: getRenderableTimeEstimate(customGasPrice, gasPrices, estimatedTimes),
+ blockTime: getBasicGasEstimateBlockTime(state),
+ customPriceIsSafe: isCustomPriceSafe(state),
+ gasPriceButtonGroupProps: {
+ buttonDataLoading,
+ defaultActiveButtonIndex: getDefaultActiveButtonIndex(gasButtonInfo, customModalGasPriceInHex),
+ gasButtonInfo,
+ },
+ gasChartProps: {
+ currentPrice: customGasPrice,
+ gasPrices,
+ estimatedTimes,
+ gasPricesMax: gasPrices[gasPrices.length - 1],
+ estimatedTimesMax: estimatedTimes[0],
+ },
+ infoRowProps: {
+ originalTotalFiat: addHexWEIsToRenderableFiat(value, gasTotal, currentCurrency, conversionRate),
+ originalTotalEth: addHexWEIsToRenderableEth(value, gasTotal),
+ newTotalFiat,
+ newTotalEth: addHexWEIsToRenderableEth(value, customGasTotal),
+ transactionFee: addHexWEIsToRenderableEth('0x0', customGasTotal),
+ sendAmount: addHexWEIsToRenderableEth(value, '0x0'),
+ },
+ isSpeedUp: transaction.status === 'submitted',
+ txId: transaction.id,
+ insufficientBalance,
+ gasEstimatesLoading,
+ }
+}
+
+const mapDispatchToProps = dispatch => {
+ const updateCustomGasPrice = newPrice => dispatch(setCustomGasPrice(addHexPrefix(newPrice)))
+
+ return {
+ cancelAndClose: () => {
+ dispatch(resetCustomData())
+ dispatch(hideModal())
+ },
+ hideModal: () => dispatch(hideModal()),
+ updateCustomGasPrice,
+ convertThenUpdateCustomGasPrice: newPrice => updateCustomGasPrice(decGWEIToHexWEI(newPrice)),
+ convertThenUpdateCustomGasLimit: newLimit => dispatch(setCustomGasLimit(addHexPrefix(newLimit.toString(16)))),
+ setGasData: (newLimit, newPrice) => {
+ dispatch(setGasLimit(newLimit))
+ dispatch(setGasPrice(newPrice))
+ },
+ updateConfirmTxGasAndCalculate: (gasLimit, gasPrice) => {
+ updateCustomGasPrice(gasPrice)
+ dispatch(setCustomGasLimit(addHexPrefix(gasLimit.toString(16))))
+ return dispatch(updateGasAndCalculate({ gasLimit, gasPrice }))
+ },
+ createSpeedUpTransaction: (txId, gasPrice) => {
+ return dispatch(createSpeedUpTransaction(txId, gasPrice))
+ },
+ hideGasButtonGroup: () => dispatch(hideGasButtonGroup()),
+ setCustomTimeEstimate: (timeEstimateInSeconds) => dispatch(setCustomTimeEstimate(timeEstimateInSeconds)),
+ hideSidebar: () => dispatch(hideSidebar()),
+ fetchGasEstimates: (blockTime) => dispatch(fetchGasEstimates(blockTime)),
+ fetchBasicGasAndTimeEstimates: () => dispatch(fetchBasicGasAndTimeEstimates()),
+ }
+}
+
+const mergeProps = (stateProps, dispatchProps, ownProps) => {
+ const { gasPriceButtonGroupProps, isConfirm, txId, isSpeedUp, insufficientBalance, customGasPrice } = stateProps
+ const {
+ updateCustomGasPrice: dispatchUpdateCustomGasPrice,
+ hideGasButtonGroup: dispatchHideGasButtonGroup,
+ setGasData: dispatchSetGasData,
+ updateConfirmTxGasAndCalculate: dispatchUpdateConfirmTxGasAndCalculate,
+ createSpeedUpTransaction: dispatchCreateSpeedUpTransaction,
+ hideSidebar: dispatchHideSidebar,
+ cancelAndClose: dispatchCancelAndClose,
+ hideModal: dispatchHideModal,
+ ...otherDispatchProps
+ } = dispatchProps
+
+ return {
+ ...stateProps,
+ ...otherDispatchProps,
+ ...ownProps,
+ onSubmit: (gasLimit, gasPrice) => {
+ if (isConfirm) {
+ dispatchUpdateConfirmTxGasAndCalculate(gasLimit, gasPrice)
+ dispatchHideModal()
+ } else if (isSpeedUp) {
+ dispatchCreateSpeedUpTransaction(txId, gasPrice)
+ dispatchHideSidebar()
+ dispatchCancelAndClose()
+ } else {
+ dispatchSetGasData(gasLimit, gasPrice)
+ dispatchHideGasButtonGroup()
+ dispatchCancelAndClose()
+ }
+ },
+ gasPriceButtonGroupProps: {
+ ...gasPriceButtonGroupProps,
+ handleGasPriceSelection: dispatchUpdateCustomGasPrice,
+ },
+ cancelAndClose: () => {
+ dispatchCancelAndClose()
+ if (isSpeedUp) {
+ dispatchHideSidebar()
+ }
+ },
+ disableSave: insufficientBalance || (isSpeedUp && customGasPrice === 0),
+ }
+}
+
+export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(GasModalPageContainer)
+
+function isConfirm (state) {
+ return Boolean(Object.keys(state.confirmTransaction.txData).length)
+}
+
+function calcCustomGasPrice (customGasPriceInHex) {
+ return Number(hexWEIToDecGWEI(customGasPriceInHex))
+}
+
+function calcCustomGasLimit (customGasLimitInHex) {
+ return parseInt(customGasLimitInHex, 16)
+}
+
+function getTxParams (state, transactionId) {
+ const { confirmTransaction: { txData }, metamask: { send } } = state
+ const pendingTransactions = submittedPendingTransactionsSelector(state)
+ const pendingTransaction = pendingTransactions.find(({ id }) => id === transactionId)
+ const { txParams: pendingTxParams } = pendingTransaction || {}
+ return txData.txParams || pendingTxParams || {
+ from: send.from,
+ gas: send.gasLimit,
+ gasPrice: send.gasPrice || getFastPriceEstimateInHexWEI(state, true),
+ to: send.to,
+ value: getSelectedToken(state) ? '0x0' : send.amount,
+ }
+}
+
+function addHexWEIsToRenderableEth (aHexWEI, bHexWEI) {
+ return pipe(
+ addHexWEIsToDec,
+ formatETHFee
+ )(aHexWEI, bHexWEI)
+}
+
+function addHexWEIsToRenderableFiat (aHexWEI, bHexWEI, convertedCurrency, conversionRate) {
+ return pipe(
+ addHexWEIsToDec,
+ partialRight(ethTotalToConvertedCurrency, [convertedCurrency, conversionRate]),
+ partialRight(formatCurrency, [convertedCurrency]),
+ )(aHexWEI, bHexWEI)
+}
+
+function getRenderableTimeEstimate (currentGasPrice, gasPrices, estimatedTimes) {
+ const minGasPrice = gasPrices[0]
+ const maxGasPrice = gasPrices[gasPrices.length - 1]
+ let priceForEstimation = currentGasPrice
+ if (currentGasPrice < minGasPrice) {
+ priceForEstimation = minGasPrice
+ } else if (currentGasPrice > maxGasPrice) {
+ priceForEstimation = maxGasPrice
+ }
+
+ const {
+ closestLowerValueIndex,
+ closestHigherValueIndex,
+ closestHigherValue,
+ closestLowerValue,
+ } = getAdjacentGasPrices({ gasPrices, priceToPosition: priceForEstimation })
+
+ const newTimeEstimate = extrapolateY({
+ higherY: estimatedTimes[closestHigherValueIndex],
+ lowerY: estimatedTimes[closestLowerValueIndex],
+ higherX: closestHigherValue,
+ lowerX: closestLowerValue,
+ xForExtrapolation: priceForEstimation,
+ })
+
+ return formatTimeEstimate(newTimeEstimate, currentGasPrice > maxGasPrice, currentGasPrice < minGasPrice)
+}
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/index.js b/ui/app/components/gas-customization/gas-modal-page-container/index.js
new file mode 100644
index 000000000..ec0ebad22
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/index.js
@@ -0,0 +1 @@
+export { default } from './gas-modal-page-container.container'
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/index.scss b/ui/app/components/gas-customization/gas-modal-page-container/index.scss
new file mode 100644
index 000000000..efba24e02
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/index.scss
@@ -0,0 +1,148 @@
+@import './advanced-tab-content/index';
+@import './basic-tab-content/index';
+
+.gas-modal-page-container {
+ .page-container {
+ max-width: 391px;
+ min-height: 585px;
+ overflow-y: initial;
+
+ @media screen and (max-width: $break-small) {
+ max-width: 344px;
+
+ &__content {
+ display: flex;
+ overflow-y: initial;
+ }
+ }
+
+ &__header {
+ padding: 0px;
+ padding-top: 16px;
+
+ &--no-padding-bottom {
+ padding-bottom: 0;
+ }
+ }
+
+ &__footer {
+ header {
+ padding-top: 12px;
+ padding-bottom: 12px;
+ }
+ }
+
+ &__header-close-text {
+ font-size: 14px;
+ color: #4EADE7;
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ cursor: pointer;
+ overflow: hidden;
+ }
+
+ &__title {
+ color: $black;
+ font-size: 16px;
+ font-weight: 500;
+ line-height: 16px;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ margin-right: 0;
+ }
+
+ &__subtitle {
+ display: none;
+ }
+
+ &__tabs {
+ margin-top: 0px;
+ }
+
+ &__tab {
+ width: 100%;
+ font-size: 14px;
+
+ &:last-of-type {
+ margin-right: 0;
+ }
+
+ &--selected {
+ color: $curious-blue;
+ border-bottom: 2px solid $curious-blue;
+ }
+ }
+ }
+}
+
+.gas-modal-content {
+ @media screen and (max-width: $break-small) {
+ width: 100%;
+ }
+
+ &__basic-tab {
+ height: 219px;
+ }
+
+
+ &__info-row, &__info-row--fade {
+ width: 100%;
+ background: $polar;
+ padding: 15px 21px;
+ display: flex;
+ flex-flow: column;
+ color: $scorpion;
+ font-size: 12px;
+
+ @media screen and (max-width: $break-small) {
+ padding: 4px 21px;
+ }
+
+ &__send-info, &__transaction-info, &__total-info, &__fiat-total-info {
+ display: flex;
+ flex-flow: row;
+ justify-content: space-between;
+ }
+
+ &__fiat-total-info {
+ justify-content: flex-end;
+ }
+
+ &__total-info {
+ &__label {
+ font-size: 16px;
+
+ @media screen and (max-width: $break-small) {
+ font-size: 14px;
+ }
+ }
+
+ &__value {
+ font-size: 16px;
+ font-weight: bold;
+
+ @media screen and (max-width: $break-small) {
+ font-size: 14px;
+ }
+ }
+ }
+
+ &__transaction-info, &__send-info {
+ &__label {
+ font-size: 12px;
+ }
+
+ &__value {
+ font-size: 14px;
+ }
+ }
+ }
+
+ &__info-row--fade {
+ background: white;
+ color: $dusty-gray;
+ border-top: 1px solid $mischka;
+ }
+}
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js b/ui/app/components/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js
new file mode 100644
index 000000000..f068c40d0
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js
@@ -0,0 +1,274 @@
+import React from 'react'
+import assert from 'assert'
+import shallow from '../../../../../lib/shallow-with-context'
+import sinon from 'sinon'
+import GasModalPageContainer from '../gas-modal-page-container.component.js'
+import timeout from '../../../../../lib/test-timeout'
+
+import PageContainer from '../../../page-container'
+
+import { Tab } from '../../../tabs'
+
+const mockBasicGasEstimates = {
+ blockTime: 'mockBlockTime',
+}
+
+const propsMethodSpies = {
+ cancelAndClose: sinon.spy(),
+ onSubmit: sinon.spy(),
+ fetchBasicGasAndTimeEstimates: sinon.stub().returns(Promise.resolve(mockBasicGasEstimates)),
+ fetchGasEstimates: sinon.spy(),
+}
+
+const mockGasPriceButtonGroupProps = {
+ buttonDataLoading: false,
+ className: 'gas-price-button-group',
+ gasButtonInfo: [
+ {
+ feeInPrimaryCurrency: '$0.52',
+ feeInSecondaryCurrency: '0.0048 ETH',
+ timeEstimate: '~ 1 min 0 sec',
+ priceInHexWei: '0xa1b2c3f',
+ },
+ {
+ feeInPrimaryCurrency: '$0.39',
+ feeInSecondaryCurrency: '0.004 ETH',
+ timeEstimate: '~ 1 min 30 sec',
+ priceInHexWei: '0xa1b2c39',
+ },
+ {
+ feeInPrimaryCurrency: '$0.30',
+ feeInSecondaryCurrency: '0.00354 ETH',
+ timeEstimate: '~ 2 min 1 sec',
+ priceInHexWei: '0xa1b2c30',
+ },
+ ],
+ handleGasPriceSelection: 'mockSelectionFunction',
+ noButtonActiveByDefault: true,
+ showCheck: true,
+ newTotalFiat: 'mockNewTotalFiat',
+ newTotalEth: 'mockNewTotalEth',
+}
+const mockInfoRowProps = {
+ originalTotalFiat: 'mockOriginalTotalFiat',
+ originalTotalEth: 'mockOriginalTotalEth',
+ newTotalFiat: 'mockNewTotalFiat',
+ newTotalEth: 'mockNewTotalEth',
+ sendAmount: 'mockSendAmount',
+ transactionFee: 'mockTransactionFee',
+}
+
+const GP = GasModalPageContainer.prototype
+describe('GasModalPageContainer Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<GasModalPageContainer
+ cancelAndClose={propsMethodSpies.cancelAndClose}
+ onSubmit={propsMethodSpies.onSubmit}
+ fetchBasicGasAndTimeEstimates={propsMethodSpies.fetchBasicGasAndTimeEstimates}
+ fetchGasEstimates={propsMethodSpies.fetchGasEstimates}
+ updateCustomGasPrice={() => 'mockupdateCustomGasPrice'}
+ updateCustomGasLimit={() => 'mockupdateCustomGasLimit'}
+ customGasPrice={21}
+ customGasLimit={54321}
+ gasPriceButtonGroupProps={mockGasPriceButtonGroupProps}
+ infoRowProps={mockInfoRowProps}
+ currentTimeEstimate={'1 min 31 sec'}
+ customGasPriceInHex={'mockCustomGasPriceInHex'}
+ customGasLimitInHex={'mockCustomGasLimitInHex'}
+ insufficientBalance={false}
+ disableSave={false}
+ />, { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } })
+ })
+
+ afterEach(() => {
+ propsMethodSpies.cancelAndClose.resetHistory()
+ })
+
+ describe('componentDidMount', () => {
+ it('should call props.fetchBasicGasAndTimeEstimates', () => {
+ propsMethodSpies.fetchBasicGasAndTimeEstimates.resetHistory()
+ assert.equal(propsMethodSpies.fetchBasicGasAndTimeEstimates.callCount, 0)
+ wrapper.instance().componentDidMount()
+ assert.equal(propsMethodSpies.fetchBasicGasAndTimeEstimates.callCount, 1)
+ })
+
+ it('should call props.fetchGasEstimates with the block time returned by fetchBasicGasAndTimeEstimates', async () => {
+ propsMethodSpies.fetchGasEstimates.resetHistory()
+ assert.equal(propsMethodSpies.fetchGasEstimates.callCount, 0)
+ wrapper.instance().componentDidMount()
+ await timeout(250)
+ assert.equal(propsMethodSpies.fetchGasEstimates.callCount, 1)
+ assert.equal(propsMethodSpies.fetchGasEstimates.getCall(0).args[0], 'mockBlockTime')
+ })
+ })
+
+ describe('render', () => {
+ it('should render a PageContainer compenent', () => {
+ assert.equal(wrapper.find(PageContainer).length, 1)
+ })
+
+ it('should pass correct props to PageContainer', () => {
+ const {
+ title,
+ subtitle,
+ disabled,
+ } = wrapper.find(PageContainer).props()
+ assert.equal(title, 'customGas')
+ assert.equal(subtitle, 'customGasSubTitle')
+ assert.equal(disabled, false)
+ })
+
+ it('should pass the correct onCancel and onClose methods to PageContainer', () => {
+ const {
+ onCancel,
+ onClose,
+ } = wrapper.find(PageContainer).props()
+ assert.equal(propsMethodSpies.cancelAndClose.callCount, 0)
+ onCancel()
+ assert.equal(propsMethodSpies.cancelAndClose.callCount, 1)
+ onClose()
+ assert.equal(propsMethodSpies.cancelAndClose.callCount, 2)
+ })
+
+ it('should pass the correct renderTabs property to PageContainer', () => {
+ sinon.stub(GP, 'renderTabs').returns('mockTabs')
+ const renderTabsWrapperTester = shallow(<GasModalPageContainer
+ fetchBasicGasAndTimeEstimates={propsMethodSpies.fetchBasicGasAndTimeEstimates}
+ fetchGasEstimates={propsMethodSpies.fetchGasEstimates}
+ />, { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } })
+ const { tabsComponent } = renderTabsWrapperTester.find(PageContainer).props()
+ assert.equal(tabsComponent, 'mockTabs')
+ GasModalPageContainer.prototype.renderTabs.restore()
+ })
+ })
+
+ describe('renderTabs', () => {
+ beforeEach(() => {
+ sinon.spy(GP, 'renderBasicTabContent')
+ sinon.spy(GP, 'renderAdvancedTabContent')
+ sinon.spy(GP, 'renderInfoRows')
+ })
+
+ afterEach(() => {
+ GP.renderBasicTabContent.restore()
+ GP.renderAdvancedTabContent.restore()
+ GP.renderInfoRows.restore()
+ })
+
+ it('should render a Tabs component with "Basic" and "Advanced" tabs', () => {
+ const renderTabsResult = wrapper.instance().renderTabs(mockInfoRowProps, {
+ gasPriceButtonGroupProps: mockGasPriceButtonGroupProps,
+ otherProps: 'mockAdvancedTabProps',
+ })
+ const renderedTabs = shallow(renderTabsResult)
+ assert.equal(renderedTabs.props().className, 'tabs')
+
+ const tabs = renderedTabs.find(Tab)
+ assert.equal(tabs.length, 2)
+
+ assert.equal(tabs.at(0).props().name, 'basic')
+ assert.equal(tabs.at(1).props().name, 'advanced')
+
+ assert.equal(tabs.at(0).childAt(0).props().className, 'gas-modal-content')
+ assert.equal(tabs.at(1).childAt(0).props().className, 'gas-modal-content')
+ })
+
+ it('should call renderBasicTabContent and renderAdvancedTabContent with the expected props', () => {
+ assert.equal(GP.renderBasicTabContent.callCount, 0)
+ assert.equal(GP.renderAdvancedTabContent.callCount, 0)
+
+ wrapper.instance().renderTabs(mockInfoRowProps, { gasPriceButtonGroupProps: mockGasPriceButtonGroupProps, otherProps: 'mockAdvancedTabProps' })
+
+ assert.equal(GP.renderBasicTabContent.callCount, 1)
+ assert.equal(GP.renderAdvancedTabContent.callCount, 1)
+
+ assert.deepEqual(GP.renderBasicTabContent.getCall(0).args[0], mockGasPriceButtonGroupProps)
+ assert.deepEqual(GP.renderAdvancedTabContent.getCall(0).args[0], { otherProps: 'mockAdvancedTabProps' })
+ })
+
+ it('should call renderInfoRows with the expected props', () => {
+ assert.equal(GP.renderInfoRows.callCount, 0)
+
+ wrapper.instance().renderTabs(mockInfoRowProps, { gasPriceButtonGroupProps: mockGasPriceButtonGroupProps, otherProps: 'mockAdvancedTabProps' })
+
+ assert.equal(GP.renderInfoRows.callCount, 2)
+
+ assert.deepEqual(GP.renderInfoRows.getCall(0).args, ['mockNewTotalFiat', 'mockNewTotalEth', 'mockSendAmount', 'mockTransactionFee'])
+ assert.deepEqual(GP.renderInfoRows.getCall(1).args, ['mockNewTotalFiat', 'mockNewTotalEth', 'mockSendAmount', 'mockTransactionFee'])
+ })
+
+ it('should not render the basic tab if hideBasic is true', () => {
+ const renderTabsResult = wrapper.instance().renderTabs(mockInfoRowProps, {
+ gasPriceButtonGroupProps: mockGasPriceButtonGroupProps,
+ otherProps: 'mockAdvancedTabProps',
+ hideBasic: true,
+ })
+
+ const renderedTabs = shallow(renderTabsResult)
+ const tabs = renderedTabs.find(Tab)
+ assert.equal(tabs.length, 1)
+ assert.equal(tabs.at(0).props().name, 'advanced')
+ })
+ })
+
+ describe('renderBasicTabContent', () => {
+ it('should render', () => {
+ const renderBasicTabContentResult = wrapper.instance().renderBasicTabContent(mockGasPriceButtonGroupProps)
+
+ assert.deepEqual(
+ renderBasicTabContentResult.props.gasPriceButtonGroupProps,
+ mockGasPriceButtonGroupProps
+ )
+ })
+ })
+
+ describe('renderAdvancedTabContent', () => {
+ it('should render with the correct props', () => {
+ const renderAdvancedTabContentResult = wrapper.instance().renderAdvancedTabContent({
+ convertThenUpdateCustomGasPrice: () => 'mockConvertThenUpdateCustomGasPrice',
+ convertThenUpdateCustomGasLimit: () => 'mockConvertThenUpdateCustomGasLimit',
+ customGasPrice: 123,
+ customGasLimit: 456,
+ newTotalFiat: '$0.30',
+ currentTimeEstimate: '1 min 31 sec',
+ gasEstimatesLoading: 'mockGasEstimatesLoading',
+ })
+ const advancedTabContentProps = renderAdvancedTabContentResult.props
+ assert.equal(advancedTabContentProps.updateCustomGasPrice(), 'mockConvertThenUpdateCustomGasPrice')
+ assert.equal(advancedTabContentProps.updateCustomGasLimit(), 'mockConvertThenUpdateCustomGasLimit')
+ assert.equal(advancedTabContentProps.customGasPrice, 123)
+ assert.equal(advancedTabContentProps.customGasLimit, 456)
+ assert.equal(advancedTabContentProps.timeRemaining, '1 min 31 sec')
+ assert.equal(advancedTabContentProps.totalFee, '$0.30')
+ assert.equal(advancedTabContentProps.gasEstimatesLoading, 'mockGasEstimatesLoading')
+ })
+ })
+
+ describe('renderInfoRows', () => {
+ it('should render the info rows with the passed data', () => {
+ const baseClassName = 'gas-modal-content__info-row'
+ const renderedInfoRowsContainer = shallow(wrapper.instance().renderInfoRows(
+ 'mockNewTotalFiat',
+ ' mockNewTotalEth',
+ ' mockSendAmount',
+ ' mockTransactionFee'
+ ))
+
+ assert(renderedInfoRowsContainer.childAt(0).hasClass(baseClassName))
+
+ const renderedInfoRows = renderedInfoRowsContainer.childAt(0).children()
+ assert.equal(renderedInfoRows.length, 4)
+ assert(renderedInfoRows.at(0).hasClass(`${baseClassName}__send-info`))
+ assert(renderedInfoRows.at(1).hasClass(`${baseClassName}__transaction-info`))
+ assert(renderedInfoRows.at(2).hasClass(`${baseClassName}__total-info`))
+ assert(renderedInfoRows.at(3).hasClass(`${baseClassName}__fiat-total-info`))
+
+ assert.equal(renderedInfoRows.at(0).text(), 'sendAmount mockSendAmount')
+ assert.equal(renderedInfoRows.at(1).text(), 'transactionFee mockTransactionFee')
+ assert.equal(renderedInfoRows.at(2).text(), 'newTotal mockNewTotalEth')
+ assert.equal(renderedInfoRows.at(3).text(), 'mockNewTotalFiat')
+ })
+ })
+})
diff --git a/ui/app/components/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js b/ui/app/components/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js
new file mode 100644
index 000000000..077ec471d
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js
@@ -0,0 +1,362 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+import sinon from 'sinon'
+
+let mapStateToProps
+let mapDispatchToProps
+let mergeProps
+
+const actionSpies = {
+ hideModal: sinon.spy(),
+ setGasLimit: sinon.spy(),
+ setGasPrice: sinon.spy(),
+}
+
+const gasActionSpies = {
+ setCustomGasPrice: sinon.spy(),
+ setCustomGasLimit: sinon.spy(),
+ resetCustomData: sinon.spy(),
+}
+
+const confirmTransactionActionSpies = {
+ updateGasAndCalculate: sinon.spy(),
+}
+
+const sendActionSpies = {
+ hideGasButtonGroup: sinon.spy(),
+}
+
+proxyquire('../gas-modal-page-container.container.js', {
+ 'react-redux': {
+ connect: (ms, md, mp) => {
+ mapStateToProps = ms
+ mapDispatchToProps = md
+ mergeProps = mp
+ return () => ({})
+ },
+ },
+ '../../../selectors/custom-gas': {
+ getBasicGasEstimateLoadingStatus: (s) => `mockBasicGasEstimateLoadingStatus:${Object.keys(s).length}`,
+ getRenderableBasicEstimateData: (s) => `mockRenderableBasicEstimateData:${Object.keys(s).length}`,
+ getDefaultActiveButtonIndex: (a, b) => a + b,
+ },
+ '../../../actions': actionSpies,
+ '../../../ducks/gas.duck': gasActionSpies,
+ '../../../ducks/confirm-transaction.duck': confirmTransactionActionSpies,
+ '../../../ducks/send.duck': sendActionSpies,
+ '../../../selectors.js': {
+ getCurrentEthBalance: (state) => state.metamask.balance || '0x0',
+ },
+})
+
+describe('gas-modal-page-container container', () => {
+
+ describe('mapStateToProps()', () => {
+ it('should map the correct properties to props', () => {
+ const baseMockState = {
+ appState: {
+ modal: {
+ modalState: {
+ props: {
+ hideBasic: true,
+ },
+ },
+ },
+ },
+ metamask: {
+ send: {
+ gasLimit: '16',
+ gasPrice: '32',
+ amount: '64',
+ },
+ currentCurrency: 'abc',
+ conversionRate: 50,
+ },
+ gas: {
+ basicEstimates: {
+ blockTime: 12,
+ safeLow: 2,
+ },
+ customData: {
+ limit: 'aaaaaaaa',
+ price: 'ffffffff',
+ },
+ gasEstimatesLoading: false,
+ priceAndTimeEstimates: [
+ { gasprice: 3, expectedTime: 31 },
+ { gasprice: 4, expectedTime: 62 },
+ { gasprice: 5, expectedTime: 93 },
+ { gasprice: 6, expectedTime: 124 },
+ ],
+ },
+ confirmTransaction: {
+ txData: {
+ txParams: {
+ gas: '0x1600000',
+ gasPrice: '0x3200000',
+ value: '0x640000000000000',
+ },
+ },
+ },
+ }
+ const baseExpectedResult = {
+ isConfirm: true,
+ customGasPrice: 4.294967295,
+ customGasLimit: 2863311530,
+ currentTimeEstimate: '~1 min 11 sec',
+ newTotalFiat: '637.41',
+ blockTime: 12,
+ customModalGasLimitInHex: 'aaaaaaaa',
+ customModalGasPriceInHex: 'ffffffff',
+ customPriceIsSafe: true,
+ gasChartProps: {
+ 'currentPrice': 4.294967295,
+ estimatedTimes: [31, 62, 93, 124],
+ estimatedTimesMax: '31',
+ gasPrices: [3, 4, 5, 6],
+ gasPricesMax: 6,
+ },
+ gasPriceButtonGroupProps: {
+ buttonDataLoading: 'mockBasicGasEstimateLoadingStatus:4',
+ defaultActiveButtonIndex: 'mockRenderableBasicEstimateData:4ffffffff',
+ gasButtonInfo: 'mockRenderableBasicEstimateData:4',
+ },
+ gasEstimatesLoading: false,
+ hideBasic: true,
+ infoRowProps: {
+ originalTotalFiat: '637.41',
+ originalTotalEth: '12.748189 ETH',
+ newTotalFiat: '637.41',
+ newTotalEth: '12.748189 ETH',
+ sendAmount: '0.45036 ETH',
+ transactionFee: '12.297829 ETH',
+ },
+ insufficientBalance: true,
+ isSpeedUp: false,
+ txId: 34,
+ }
+ const baseMockOwnProps = { transaction: { id: 34 } }
+ const tests = [
+ { mockState: baseMockState, expectedResult: baseExpectedResult, mockOwnProps: baseMockOwnProps },
+ {
+ mockState: Object.assign({}, baseMockState, {
+ metamask: { ...baseMockState.metamask, balance: '0xfffffffffffffffffffff' },
+ }),
+ expectedResult: Object.assign({}, baseExpectedResult, { insufficientBalance: false }),
+ mockOwnProps: baseMockOwnProps,
+ },
+ {
+ mockState: baseMockState,
+ mockOwnProps: Object.assign({}, baseMockOwnProps, {
+ transaction: { id: 34, status: 'submitted' },
+ }),
+ expectedResult: Object.assign({}, baseExpectedResult, { isSpeedUp: true }),
+ },
+ ]
+
+ let result
+ tests.forEach(({ mockState, mockOwnProps, expectedResult}) => {
+ result = mapStateToProps(mockState, mockOwnProps)
+ assert.deepEqual(result, expectedResult)
+ })
+ })
+
+ })
+
+ describe('mapDispatchToProps()', () => {
+ let dispatchSpy
+ let mapDispatchToPropsObject
+
+ beforeEach(() => {
+ dispatchSpy = sinon.spy()
+ mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
+ })
+
+ afterEach(() => {
+ actionSpies.hideModal.resetHistory()
+ gasActionSpies.setCustomGasPrice.resetHistory()
+ gasActionSpies.setCustomGasLimit.resetHistory()
+ })
+
+ describe('hideGasButtonGroup()', () => {
+ it('should dispatch a hideGasButtonGroup action', () => {
+ mapDispatchToPropsObject.hideGasButtonGroup()
+ assert(dispatchSpy.calledOnce)
+ assert(sendActionSpies.hideGasButtonGroup.calledOnce)
+ })
+ })
+
+ describe('cancelAndClose()', () => {
+ it('should dispatch a hideModal action', () => {
+ mapDispatchToPropsObject.cancelAndClose()
+ assert(dispatchSpy.calledTwice)
+ assert(actionSpies.hideModal.calledOnce)
+ assert(gasActionSpies.resetCustomData.calledOnce)
+ })
+ })
+
+ describe('updateCustomGasPrice()', () => {
+ it('should dispatch a setCustomGasPrice action with the arg passed to updateCustomGasPrice hex prefixed', () => {
+ mapDispatchToPropsObject.updateCustomGasPrice('ffff')
+ assert(dispatchSpy.calledOnce)
+ assert(gasActionSpies.setCustomGasPrice.calledOnce)
+ assert.equal(gasActionSpies.setCustomGasPrice.getCall(0).args[0], '0xffff')
+ })
+ })
+
+ describe('convertThenUpdateCustomGasPrice()', () => {
+ it('should dispatch a setCustomGasPrice action with the arg passed to convertThenUpdateCustomGasPrice converted to WEI', () => {
+ mapDispatchToPropsObject.convertThenUpdateCustomGasPrice('0xffff')
+ assert(dispatchSpy.calledOnce)
+ assert(gasActionSpies.setCustomGasPrice.calledOnce)
+ assert.equal(gasActionSpies.setCustomGasPrice.getCall(0).args[0], '0x3b9a8e653600')
+ })
+ })
+
+
+ describe('convertThenUpdateCustomGasLimit()', () => {
+ it('should dispatch a setCustomGasLimit action with the arg passed to convertThenUpdateCustomGasLimit converted to hex', () => {
+ mapDispatchToPropsObject.convertThenUpdateCustomGasLimit(16)
+ assert(dispatchSpy.calledOnce)
+ assert(gasActionSpies.setCustomGasLimit.calledOnce)
+ assert.equal(gasActionSpies.setCustomGasLimit.getCall(0).args[0], '0x10')
+ })
+ })
+
+ describe('setGasData()', () => {
+ it('should dispatch a setGasPrice and setGasLimit action with the correct props', () => {
+ mapDispatchToPropsObject.setGasData('ffff', 'aaaa')
+ assert(dispatchSpy.calledTwice)
+ assert(actionSpies.setGasPrice.calledOnce)
+ assert(actionSpies.setGasLimit.calledOnce)
+ assert.equal(actionSpies.setGasLimit.getCall(0).args[0], 'ffff')
+ assert.equal(actionSpies.setGasPrice.getCall(0).args[0], 'aaaa')
+ })
+ })
+
+ describe('updateConfirmTxGasAndCalculate()', () => {
+ it('should dispatch a updateGasAndCalculate action with the correct props', () => {
+ mapDispatchToPropsObject.updateConfirmTxGasAndCalculate('ffff', 'aaaa')
+ assert.equal(dispatchSpy.callCount, 3)
+ assert(confirmTransactionActionSpies.updateGasAndCalculate.calledOnce)
+ assert.deepEqual(confirmTransactionActionSpies.updateGasAndCalculate.getCall(0).args[0], { gasLimit: 'ffff', gasPrice: 'aaaa' })
+ })
+ })
+
+ })
+
+ describe('mergeProps', () => {
+ let stateProps
+ let dispatchProps
+ let ownProps
+
+ beforeEach(() => {
+ stateProps = {
+ gasPriceButtonGroupProps: {
+ someGasPriceButtonGroupProp: 'foo',
+ anotherGasPriceButtonGroupProp: 'bar',
+ },
+ isConfirm: true,
+ someOtherStateProp: 'baz',
+ }
+ dispatchProps = {
+ updateCustomGasPrice: sinon.spy(),
+ hideGasButtonGroup: sinon.spy(),
+ setGasData: sinon.spy(),
+ updateConfirmTxGasAndCalculate: sinon.spy(),
+ someOtherDispatchProp: sinon.spy(),
+ createSpeedUpTransaction: sinon.spy(),
+ hideSidebar: sinon.spy(),
+ hideModal: sinon.spy(),
+ cancelAndClose: sinon.spy(),
+ }
+ ownProps = { someOwnProp: 123 }
+ })
+
+ afterEach(() => {
+ dispatchProps.updateCustomGasPrice.resetHistory()
+ dispatchProps.hideGasButtonGroup.resetHistory()
+ dispatchProps.setGasData.resetHistory()
+ dispatchProps.updateConfirmTxGasAndCalculate.resetHistory()
+ dispatchProps.someOtherDispatchProp.resetHistory()
+ dispatchProps.createSpeedUpTransaction.resetHistory()
+ dispatchProps.hideSidebar.resetHistory()
+ dispatchProps.hideModal.resetHistory()
+ })
+ it('should return the expected props when isConfirm is true', () => {
+ const result = mergeProps(stateProps, dispatchProps, ownProps)
+
+ assert.equal(result.isConfirm, true)
+ assert.equal(result.someOtherStateProp, 'baz')
+ assert.equal(result.gasPriceButtonGroupProps.someGasPriceButtonGroupProp, 'foo')
+ assert.equal(result.gasPriceButtonGroupProps.anotherGasPriceButtonGroupProp, 'bar')
+ assert.equal(result.someOwnProp, 123)
+
+ assert.equal(dispatchProps.updateConfirmTxGasAndCalculate.callCount, 0)
+ assert.equal(dispatchProps.setGasData.callCount, 0)
+ assert.equal(dispatchProps.hideGasButtonGroup.callCount, 0)
+ assert.equal(dispatchProps.hideModal.callCount, 0)
+
+ result.onSubmit()
+
+ assert.equal(dispatchProps.updateConfirmTxGasAndCalculate.callCount, 1)
+ assert.equal(dispatchProps.setGasData.callCount, 0)
+ assert.equal(dispatchProps.hideGasButtonGroup.callCount, 0)
+ assert.equal(dispatchProps.hideModal.callCount, 1)
+
+ assert.equal(dispatchProps.updateCustomGasPrice.callCount, 0)
+ result.gasPriceButtonGroupProps.handleGasPriceSelection()
+ assert.equal(dispatchProps.updateCustomGasPrice.callCount, 1)
+
+ assert.equal(dispatchProps.someOtherDispatchProp.callCount, 0)
+ result.someOtherDispatchProp()
+ assert.equal(dispatchProps.someOtherDispatchProp.callCount, 1)
+ })
+
+ it('should return the expected props when isConfirm is false', () => {
+ const result = mergeProps(Object.assign({}, stateProps, { isConfirm: false }), dispatchProps, ownProps)
+
+ assert.equal(result.isConfirm, false)
+ assert.equal(result.someOtherStateProp, 'baz')
+ assert.equal(result.gasPriceButtonGroupProps.someGasPriceButtonGroupProp, 'foo')
+ assert.equal(result.gasPriceButtonGroupProps.anotherGasPriceButtonGroupProp, 'bar')
+ assert.equal(result.someOwnProp, 123)
+
+ assert.equal(dispatchProps.updateConfirmTxGasAndCalculate.callCount, 0)
+ assert.equal(dispatchProps.setGasData.callCount, 0)
+ assert.equal(dispatchProps.hideGasButtonGroup.callCount, 0)
+ assert.equal(dispatchProps.cancelAndClose.callCount, 0)
+
+ result.onSubmit('mockNewLimit', 'mockNewPrice')
+
+ assert.equal(dispatchProps.updateConfirmTxGasAndCalculate.callCount, 0)
+ assert.equal(dispatchProps.setGasData.callCount, 1)
+ assert.deepEqual(dispatchProps.setGasData.getCall(0).args, ['mockNewLimit', 'mockNewPrice'])
+ assert.equal(dispatchProps.hideGasButtonGroup.callCount, 1)
+ assert.equal(dispatchProps.cancelAndClose.callCount, 1)
+
+ assert.equal(dispatchProps.updateCustomGasPrice.callCount, 0)
+ result.gasPriceButtonGroupProps.handleGasPriceSelection()
+ assert.equal(dispatchProps.updateCustomGasPrice.callCount, 1)
+
+ assert.equal(dispatchProps.someOtherDispatchProp.callCount, 0)
+ result.someOtherDispatchProp()
+ assert.equal(dispatchProps.someOtherDispatchProp.callCount, 1)
+ })
+
+ it('should dispatch the expected actions from obSubmit when isConfirm is false and isSpeedUp is true', () => {
+ const result = mergeProps(Object.assign({}, stateProps, { isSpeedUp: true, isConfirm: false }), dispatchProps, ownProps)
+
+ result.onSubmit()
+
+ assert.equal(dispatchProps.updateConfirmTxGasAndCalculate.callCount, 0)
+ assert.equal(dispatchProps.setGasData.callCount, 0)
+ assert.equal(dispatchProps.hideGasButtonGroup.callCount, 0)
+ assert.equal(dispatchProps.cancelAndClose.callCount, 1)
+
+ assert.equal(dispatchProps.createSpeedUpTransaction.callCount, 1)
+ assert.equal(dispatchProps.hideSidebar.callCount, 1)
+ })
+ })
+
+})
diff --git a/ui/app/components/gas-customization/gas-price-button-group/gas-price-button-group.component.js b/ui/app/components/gas-customization/gas-price-button-group/gas-price-button-group.component.js
new file mode 100644
index 000000000..8ad063b21
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-price-button-group/gas-price-button-group.component.js
@@ -0,0 +1,89 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import ButtonGroup from '../../button-group'
+import Button from '../../button'
+
+const GAS_OBJECT_PROPTYPES_SHAPE = {
+ label: PropTypes.string,
+ feeInPrimaryCurrency: PropTypes.string,
+ feeInSecondaryCurrency: PropTypes.string,
+ timeEstimate: PropTypes.string,
+ priceInHexWei: PropTypes.string,
+}
+
+export default class GasPriceButtonGroup extends Component {
+ static contextTypes = {
+ t: PropTypes.func,
+ }
+
+ static propTypes = {
+ buttonDataLoading: PropTypes.bool,
+ className: PropTypes.string,
+ defaultActiveButtonIndex: PropTypes.number,
+ gasButtonInfo: PropTypes.arrayOf(PropTypes.shape(GAS_OBJECT_PROPTYPES_SHAPE)),
+ handleGasPriceSelection: PropTypes.func,
+ newActiveButtonIndex: PropTypes.number,
+ noButtonActiveByDefault: PropTypes.bool,
+ showCheck: PropTypes.bool,
+ }
+
+ renderButtonContent ({
+ labelKey,
+ feeInPrimaryCurrency,
+ feeInSecondaryCurrency,
+ timeEstimate,
+ }, {
+ className,
+ showCheck,
+ }) {
+ return (<div>
+ { labelKey && <div className={`${className}__label`}>{ this.context.t(labelKey) }</div> }
+ { timeEstimate && <div className={`${className}__time-estimate`}>{ timeEstimate }</div> }
+ { feeInPrimaryCurrency && <div className={`${className}__primary-currency`}>{ feeInPrimaryCurrency }</div> }
+ { feeInSecondaryCurrency && <div className={`${className}__secondary-currency`}>{ feeInSecondaryCurrency }</div> }
+ { showCheck && <div className="button-check-wrapper"><i className="fa fa-check fa-sm" /></div> }
+ </div>)
+ }
+
+ renderButton ({
+ priceInHexWei,
+ ...renderableGasInfo
+ }, {
+ buttonDataLoading,
+ handleGasPriceSelection,
+ ...buttonContentPropsAndFlags
+ }, index) {
+ return (
+ <Button
+ onClick={() => handleGasPriceSelection(priceInHexWei)}
+ key={`gas-price-button-${index}`}
+ >
+ {this.renderButtonContent(renderableGasInfo, buttonContentPropsAndFlags)}
+ </Button>
+ )
+ }
+
+ render () {
+ const {
+ gasButtonInfo,
+ defaultActiveButtonIndex = 1,
+ newActiveButtonIndex,
+ noButtonActiveByDefault = false,
+ buttonDataLoading,
+ ...buttonPropsAndFlags
+ } = this.props
+
+ return (
+ !buttonDataLoading
+ ? <ButtonGroup
+ className={buttonPropsAndFlags.className}
+ defaultActiveButtonIndex={defaultActiveButtonIndex}
+ newActiveButtonIndex={newActiveButtonIndex}
+ noButtonActiveByDefault={noButtonActiveByDefault}
+ >
+ { gasButtonInfo.map((obj, index) => this.renderButton(obj, buttonPropsAndFlags, index)) }
+ </ButtonGroup>
+ : <div className={`${buttonPropsAndFlags.className}__loading-container`}>{ this.context.t('loading') }</div>
+ )
+ }
+}
diff --git a/ui/app/components/gas-customization/gas-price-button-group/index.js b/ui/app/components/gas-customization/gas-price-button-group/index.js
new file mode 100644
index 000000000..775648330
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-price-button-group/index.js
@@ -0,0 +1 @@
+export { default } from './gas-price-button-group.component'
diff --git a/ui/app/components/gas-customization/gas-price-button-group/index.scss b/ui/app/components/gas-customization/gas-price-button-group/index.scss
new file mode 100644
index 000000000..c8b31fc83
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-price-button-group/index.scss
@@ -0,0 +1,235 @@
+.gas-price-button-group {
+ margin-top: 22px;
+ display: flex;
+ justify-content: space-evenly;
+ width: 100%;
+ padding-left: 20px;
+ padding-right: 20px;
+
+ &__primary-currency {
+ font-size: 18px;
+ height: 20.5px;
+ margin-bottom: 7.5px;
+ }
+
+ &__time-estimate {
+ margin-top: 5.5px;
+ color: $silver-chalice;
+ height: 15.4px;
+ }
+
+ &__loading-container {
+ height: 130px;
+ }
+
+ .button-group__button, .button-group__button--active {
+ height: 130px;
+ max-width: 108px;
+ font-size: 12px;
+ flex-direction: column;
+ align-items: center;
+ display: flex;
+ padding-top: 17px;
+ border-radius: 4px;
+ border: 2px solid $spindle;
+ background: $white;
+ color: $scorpion;
+
+ div {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+
+ i {
+ &:last-child {
+ display: none;
+ }
+ }
+ }
+
+ .button-group__button--active {
+ border: 2px solid $curious-blue;
+ color: $scorpion;
+
+ i {
+ &:last-child {
+ display: flex;
+ color: $curious-blue;
+ margin-top: 8px
+ }
+ }
+ }
+}
+
+.gas-price-button-group--small {
+ display: flex;
+ justify-content: stretch;
+ max-width: 260px;
+
+ &__button-fiat-price {
+ font-size: 13px;
+ }
+
+ &__button-label {
+ font-size: 16px;
+ }
+
+ &__label {
+ font-weight: 500;
+ }
+
+ &__primary-currency {
+ font-size: 12px;
+
+ @media screen and (max-width: 575px) {
+ font-size: 10px;
+ }
+ }
+
+ &__secondary-currency {
+ font-size: 12px;
+
+ @media screen and (max-width: 575px) {
+ font-size: 10px;
+ }
+ }
+
+ &__loading-container {
+ height: 78px;
+ }
+
+ .button-group__button, .button-group__button--active {
+ height: 78px;
+ background: white;
+ color: $scorpion;
+ padding-top: 9px;
+ padding-left: 8.5px;
+
+ @media screen and (max-width: $break-small) {
+ padding-left: 4px;
+ }
+
+ div {
+ display: flex;
+ flex-flow: column;
+ align-items: flex-start;
+ justify-content: flex-start;
+ }
+
+ i {
+ &:last-child {
+ display: none;
+ }
+ }
+ }
+
+ .button-group__button--active {
+ color: $white;
+ background: $dodger-blue;
+
+ i {
+ &:last-child {
+ display: flex;
+ color: $curious-blue;
+ margin-top: 10px
+ }
+ }
+ }
+}
+
+.gas-price-button-group--alt {
+ display: flex;
+ justify-content: stretch;
+ width: 95%;
+
+ &__button-fiat-price {
+ font-size: 13px;
+ }
+
+ &__button-label {
+ font-size: 16px;
+ }
+
+ &__label {
+ font-weight: 500;
+ font-size: 10px;
+ text-transform: capitalize;
+ }
+
+ &__primary-currency {
+ font-size: 11px;
+ margin-top: 3px;
+ }
+
+ &__secondary-currency {
+ font-size: 11px;
+ }
+
+ &__loading-container {
+ height: 78px;
+ }
+
+ &__time-estimate {
+ font-size: 14px;
+ font-weight: 500;
+ margin-top: 4px;
+ color: $black;
+ }
+
+ .button-group__button, .button-group__button--active {
+ height: 78px;
+ background: white;
+ color: #2A4055;
+ width: 108px;
+ height: 97px;
+ box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.151579);
+ border-radius: 6px;
+ border: none;
+
+ div {
+ display: flex;
+ flex-flow: column;;
+ align-items: flex-start;
+ justify-content: flex-start;
+ position: relative;
+ }
+
+ .button-check-wrapper {
+ display: none;
+ }
+
+ &:first-child {
+ margin-right: 6px;
+ }
+
+ &:last-child {
+ margin-left: 6px;
+ }
+ }
+
+ .button-group__button--active {
+ background: #F7FCFF;
+ border: 2px solid #2C8BDC;
+
+ .button-check-wrapper {
+ height: 16px;
+ width: 16px;
+ border-radius: 8px;
+ position: absolute;
+ top: -11px;
+ right: -10px;
+ background: #D5ECFA;
+ display: flex;
+ flex-flow: row;
+ justify-content: center;
+ align-items: center;
+ }
+
+ i {
+ display: flex;
+ color: $curious-blue;
+ font-size: 12px;
+ }
+ }
+}
diff --git a/ui/app/components/gas-customization/gas-price-button-group/tests/gas-price-button-group-component.test.js b/ui/app/components/gas-customization/gas-price-button-group/tests/gas-price-button-group-component.test.js
new file mode 100644
index 000000000..79f74f8e4
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-price-button-group/tests/gas-price-button-group-component.test.js
@@ -0,0 +1,233 @@
+import React from 'react'
+import assert from 'assert'
+import shallow from '../../../../../lib/shallow-with-context'
+import sinon from 'sinon'
+import GasPriceButtonGroup from '../gas-price-button-group.component'
+
+import ButtonGroup from '../../../button-group/'
+
+const mockGasPriceButtonGroupProps = {
+ buttonDataLoading: false,
+ className: 'gas-price-button-group',
+ gasButtonInfo: [
+ {
+ feeInPrimaryCurrency: '$0.52',
+ feeInSecondaryCurrency: '0.0048 ETH',
+ timeEstimate: '~ 1 min 0 sec',
+ priceInHexWei: '0xa1b2c3f',
+ },
+ {
+ feeInPrimaryCurrency: '$0.39',
+ feeInSecondaryCurrency: '0.004 ETH',
+ timeEstimate: '~ 1 min 30 sec',
+ priceInHexWei: '0xa1b2c39',
+ },
+ {
+ feeInPrimaryCurrency: '$0.30',
+ feeInSecondaryCurrency: '0.00354 ETH',
+ timeEstimate: '~ 2 min 1 sec',
+ priceInHexWei: '0xa1b2c30',
+ },
+ ],
+ handleGasPriceSelection: sinon.spy(),
+ noButtonActiveByDefault: true,
+ defaultActiveButtonIndex: 2,
+ showCheck: true,
+}
+
+const mockButtonPropsAndFlags = Object.assign({}, {
+ className: mockGasPriceButtonGroupProps.className,
+ handleGasPriceSelection: mockGasPriceButtonGroupProps.handleGasPriceSelection,
+ showCheck: mockGasPriceButtonGroupProps.showCheck,
+})
+
+sinon.spy(GasPriceButtonGroup.prototype, 'renderButton')
+sinon.spy(GasPriceButtonGroup.prototype, 'renderButtonContent')
+
+describe('GasPriceButtonGroup Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<GasPriceButtonGroup
+ {...mockGasPriceButtonGroupProps}
+ />)
+ })
+
+ afterEach(() => {
+ GasPriceButtonGroup.prototype.renderButton.resetHistory()
+ GasPriceButtonGroup.prototype.renderButtonContent.resetHistory()
+ mockGasPriceButtonGroupProps.handleGasPriceSelection.resetHistory()
+ })
+
+ describe('render', () => {
+ it('should render a ButtonGroup', () => {
+ assert(wrapper.is(ButtonGroup))
+ })
+
+ it('should render the correct props on the ButtonGroup', () => {
+ const {
+ className,
+ defaultActiveButtonIndex,
+ noButtonActiveByDefault,
+ } = wrapper.props()
+ assert.equal(className, 'gas-price-button-group')
+ assert.equal(defaultActiveButtonIndex, 2)
+ assert.equal(noButtonActiveByDefault, true)
+ })
+
+ function renderButtonArgsTest (i, mockButtonPropsAndFlags) {
+ assert.deepEqual(
+ GasPriceButtonGroup.prototype.renderButton.getCall(i).args,
+ [
+ Object.assign({}, mockGasPriceButtonGroupProps.gasButtonInfo[i]),
+ mockButtonPropsAndFlags,
+ i,
+ ]
+ )
+ }
+
+ it('should call this.renderButton 3 times, with the correct args', () => {
+ assert.equal(GasPriceButtonGroup.prototype.renderButton.callCount, 3)
+ renderButtonArgsTest(0, mockButtonPropsAndFlags)
+ renderButtonArgsTest(1, mockButtonPropsAndFlags)
+ renderButtonArgsTest(2, mockButtonPropsAndFlags)
+ })
+
+ it('should show loading if buttonDataLoading', () => {
+ wrapper.setProps({ buttonDataLoading: true })
+ assert(wrapper.is('div'))
+ assert(wrapper.hasClass('gas-price-button-group__loading-container'))
+ assert.equal(wrapper.text(), 'loading')
+ })
+ })
+
+ describe('renderButton', () => {
+ let wrappedRenderButtonResult
+
+ beforeEach(() => {
+ GasPriceButtonGroup.prototype.renderButtonContent.resetHistory()
+ const renderButtonResult = GasPriceButtonGroup.prototype.renderButton(
+ Object.assign({}, mockGasPriceButtonGroupProps.gasButtonInfo[0]),
+ mockButtonPropsAndFlags
+ )
+ wrappedRenderButtonResult = shallow(renderButtonResult)
+ })
+
+ it('should render a button', () => {
+ assert.equal(wrappedRenderButtonResult.type(), 'button')
+ })
+
+ it('should call the correct method when clicked', () => {
+ assert.equal(mockGasPriceButtonGroupProps.handleGasPriceSelection.callCount, 0)
+ wrappedRenderButtonResult.props().onClick()
+ assert.equal(mockGasPriceButtonGroupProps.handleGasPriceSelection.callCount, 1)
+ assert.deepEqual(
+ mockGasPriceButtonGroupProps.handleGasPriceSelection.getCall(0).args,
+ [mockGasPriceButtonGroupProps.gasButtonInfo[0].priceInHexWei]
+ )
+ })
+
+ it('should call this.renderButtonContent with the correct args', () => {
+ assert.equal(GasPriceButtonGroup.prototype.renderButtonContent.callCount, 1)
+ const {
+ feeInPrimaryCurrency,
+ feeInSecondaryCurrency,
+ timeEstimate,
+ } = mockGasPriceButtonGroupProps.gasButtonInfo[0]
+ const {
+ showCheck,
+ className,
+ } = mockGasPriceButtonGroupProps
+ assert.deepEqual(
+ GasPriceButtonGroup.prototype.renderButtonContent.getCall(0).args,
+ [
+ {
+ feeInPrimaryCurrency,
+ feeInSecondaryCurrency,
+ timeEstimate,
+ },
+ {
+ showCheck,
+ className,
+ },
+ ]
+ )
+ })
+ })
+
+ describe('renderButtonContent', () => {
+ it('should render a label if passed a labelKey', () => {
+ const renderButtonContentResult = wrapper.instance().renderButtonContent({
+ labelKey: 'mockLabelKey',
+ }, {
+ className: 'someClass',
+ })
+ const wrappedRenderButtonContentResult = shallow(renderButtonContentResult)
+ assert.equal(wrappedRenderButtonContentResult.childAt(0).children().length, 1)
+ assert.equal(wrappedRenderButtonContentResult.find('.someClass__label').text(), 'mockLabelKey')
+ })
+
+ it('should render a feeInPrimaryCurrency if passed a feeInPrimaryCurrency', () => {
+ const renderButtonContentResult = GasPriceButtonGroup.prototype.renderButtonContent({
+ feeInPrimaryCurrency: 'mockFeeInPrimaryCurrency',
+ }, {
+ className: 'someClass',
+ })
+ const wrappedRenderButtonContentResult = shallow(renderButtonContentResult)
+ assert.equal(wrappedRenderButtonContentResult.childAt(0).children().length, 1)
+ assert.equal(wrappedRenderButtonContentResult.find('.someClass__primary-currency').text(), 'mockFeeInPrimaryCurrency')
+ })
+
+ it('should render a feeInSecondaryCurrency if passed a feeInSecondaryCurrency', () => {
+ const renderButtonContentResult = GasPriceButtonGroup.prototype.renderButtonContent({
+ feeInSecondaryCurrency: 'mockFeeInSecondaryCurrency',
+ }, {
+ className: 'someClass',
+ })
+ const wrappedRenderButtonContentResult = shallow(renderButtonContentResult)
+ assert.equal(wrappedRenderButtonContentResult.childAt(0).children().length, 1)
+ assert.equal(wrappedRenderButtonContentResult.find('.someClass__secondary-currency').text(), 'mockFeeInSecondaryCurrency')
+ })
+
+ it('should render a timeEstimate if passed a timeEstimate', () => {
+ const renderButtonContentResult = GasPriceButtonGroup.prototype.renderButtonContent({
+ timeEstimate: 'mockTimeEstimate',
+ }, {
+ className: 'someClass',
+ })
+ const wrappedRenderButtonContentResult = shallow(renderButtonContentResult)
+ assert.equal(wrappedRenderButtonContentResult.childAt(0).children().length, 1)
+ assert.equal(wrappedRenderButtonContentResult.find('.someClass__time-estimate').text(), 'mockTimeEstimate')
+ })
+
+ it('should render a check if showCheck is true', () => {
+ const renderButtonContentResult = GasPriceButtonGroup.prototype.renderButtonContent({}, {
+ className: 'someClass',
+ showCheck: true,
+ })
+ const wrappedRenderButtonContentResult = shallow(renderButtonContentResult)
+ assert.equal(wrappedRenderButtonContentResult.find('.fa-check').length, 1)
+ })
+
+ it('should render all elements if all args passed', () => {
+ const renderButtonContentResult = wrapper.instance().renderButtonContent({
+ labelKey: 'mockLabel',
+ feeInPrimaryCurrency: 'mockFeeInPrimaryCurrency',
+ feeInSecondaryCurrency: 'mockFeeInSecondaryCurrency',
+ timeEstimate: 'mockTimeEstimate',
+ }, {
+ className: 'someClass',
+ showCheck: true,
+ })
+ const wrappedRenderButtonContentResult = shallow(renderButtonContentResult)
+ assert.equal(wrappedRenderButtonContentResult.children().length, 5)
+ })
+
+
+ it('should render no elements if all args passed', () => {
+ const renderButtonContentResult = GasPriceButtonGroup.prototype.renderButtonContent({}, {})
+ const wrappedRenderButtonContentResult = shallow(renderButtonContentResult)
+ assert.equal(wrappedRenderButtonContentResult.children().length, 0)
+ })
+ })
+})
diff --git a/ui/app/components/gas-customization/gas-price-chart/gas-price-chart.component.js b/ui/app/components/gas-customization/gas-price-chart/gas-price-chart.component.js
new file mode 100644
index 000000000..d4c67bbde
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-price-chart/gas-price-chart.component.js
@@ -0,0 +1,108 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import * as d3 from 'd3'
+import {
+ generateChart,
+ getCoordinateData,
+ handleChartUpdate,
+ hideDataUI,
+ setTickPosition,
+ handleMouseMove,
+} from './gas-price-chart.utils.js'
+
+export default class GasPriceChart extends Component {
+ static contextTypes = {
+ t: PropTypes.func,
+ }
+
+ static propTypes = {
+ gasPrices: PropTypes.array,
+ estimatedTimes: PropTypes.array,
+ gasPricesMax: PropTypes.number,
+ estimatedTimesMax: PropTypes.number,
+ currentPrice: PropTypes.number,
+ updateCustomGasPrice: PropTypes.func,
+ }
+
+ renderChart ({
+ currentPrice,
+ gasPrices,
+ estimatedTimes,
+ gasPricesMax,
+ estimatedTimesMax,
+ updateCustomGasPrice,
+ }) {
+ const chart = generateChart(gasPrices, estimatedTimes, gasPricesMax, estimatedTimesMax)
+ setTimeout(function () {
+ setTickPosition('y', 0, -5, 8)
+ setTickPosition('y', 1, -3, -5)
+ setTickPosition('x', 0, 3)
+ setTickPosition('x', 1, 3, -8)
+
+ const { x: domainX } = getCoordinateData('.domain')
+ const { x: yAxisX } = getCoordinateData('.c3-axis-y-label')
+ const { x: tickX } = getCoordinateData('.c3-axis-x .tick')
+
+ d3.select('.c3-axis-x .tick').attr('transform', 'translate(' + (domainX - tickX) / 2 + ', 0)')
+ d3.select('.c3-axis-x-label').attr('transform', 'translate(0,-15)')
+ d3.select('.c3-axis-y-label').attr('transform', 'translate(' + (domainX - yAxisX - 12) + ', 2) rotate(-90)')
+ d3.select('.c3-xgrid-focus line').attr('y2', 98)
+
+ d3.select('.c3-chart').on('mouseout', () => {
+ hideDataUI(chart, '#overlayed-circle')
+ })
+
+ d3.select('.c3-chart').on('click', () => {
+ const { x: newGasPrice } = d3.select('#overlayed-circle').datum()
+ updateCustomGasPrice(newGasPrice)
+ })
+
+ const { x: chartXStart, width: chartWidth } = getCoordinateData('.c3-areas-data1')
+
+ handleChartUpdate({
+ chart,
+ gasPrices,
+ newPrice: currentPrice,
+ cssId: '#set-circle',
+ })
+
+ d3.select('.c3-chart').on('mousemove', function () {
+ handleMouseMove({
+ xMousePos: d3.event.clientX,
+ chartXStart,
+ chartWidth,
+ gasPrices,
+ estimatedTimes,
+ chart,
+ })
+ })
+ }, 0)
+
+ this.chart = chart
+ }
+
+ componentDidUpdate (prevProps) {
+ const { gasPrices, currentPrice: newPrice } = this.props
+
+ if (prevProps.currentPrice !== newPrice) {
+ handleChartUpdate({
+ chart: this.chart,
+ gasPrices,
+ newPrice,
+ cssId: '#set-circle',
+ })
+ }
+ }
+
+ componentDidMount () {
+ this.renderChart(this.props)
+ }
+
+ render () {
+ return (
+ <div className="gas-price-chart" id="container">
+ <div className="gas-price-chart__root" id="chart"></div>
+ </div>
+ )
+ }
+}
diff --git a/ui/app/components/gas-customization/gas-price-chart/gas-price-chart.utils.js b/ui/app/components/gas-customization/gas-price-chart/gas-price-chart.utils.js
new file mode 100644
index 000000000..f19dafcc1
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-price-chart/gas-price-chart.utils.js
@@ -0,0 +1,354 @@
+import * as d3 from 'd3'
+import c3 from 'c3'
+import BigNumber from 'bignumber.js'
+
+const newBigSigDig = n => (new BigNumber(n.toPrecision(15)))
+const createOp = (a, b, op) => (newBigSigDig(a))[op](newBigSigDig(b))
+const bigNumMinus = (a = 0, b = 0) => createOp(a, b, 'minus')
+const bigNumDiv = (a = 0, b = 1) => createOp(a, b, 'div')
+
+export function handleMouseMove ({ xMousePos, chartXStart, chartWidth, gasPrices, estimatedTimes, chart }) {
+ const { currentPosValue, newTimeEstimate } = getNewXandTimeEstimate({
+ xMousePos,
+ chartXStart,
+ chartWidth,
+ gasPrices,
+ estimatedTimes,
+ })
+
+ if (currentPosValue === null && newTimeEstimate === null) {
+ hideDataUI(chart, '#overlayed-circle')
+ return
+ }
+
+ const indexOfNewCircle = estimatedTimes.length + 1
+ const dataUIObj = generateDataUIObj(currentPosValue, indexOfNewCircle, newTimeEstimate)
+
+ chart.internal.overlayPoint(dataUIObj, indexOfNewCircle)
+ chart.internal.showTooltip([dataUIObj], d3.select('.c3-areas-data1')._groups[0])
+ chart.internal.showXGridFocus([dataUIObj])
+}
+
+export function getCoordinateData (selector) {
+ const node = d3.select(selector).node()
+ return node ? node.getBoundingClientRect() : {}
+}
+
+export function generateDataUIObj (x, index, value) {
+ return {
+ x,
+ value,
+ index,
+ id: 'data1',
+ name: 'data1',
+ }
+}
+
+export function handleChartUpdate ({ chart, gasPrices, newPrice, cssId }) {
+ const {
+ closestLowerValueIndex,
+ closestLowerValue,
+ closestHigherValueIndex,
+ closestHigherValue,
+ } = getAdjacentGasPrices({ gasPrices, priceToPosition: newPrice })
+
+ if (closestLowerValue && closestHigherValue) {
+ setSelectedCircle({
+ chart,
+ newPrice,
+ closestLowerValueIndex,
+ closestLowerValue,
+ closestHigherValueIndex,
+ closestHigherValue,
+ })
+ } else {
+ hideDataUI(chart, cssId)
+ }
+}
+
+export function getAdjacentGasPrices ({ gasPrices, priceToPosition }) {
+ const closestLowerValueIndex = gasPrices.findIndex((e, i, a) => e <= priceToPosition && a[i + 1] >= priceToPosition)
+ const closestHigherValueIndex = gasPrices.findIndex((e, i, a) => e > priceToPosition)
+ return {
+ closestLowerValueIndex,
+ closestHigherValueIndex,
+ closestHigherValue: gasPrices[closestHigherValueIndex],
+ closestLowerValue: gasPrices[closestLowerValueIndex],
+ }
+}
+
+export function extrapolateY ({ higherY = 0, lowerY = 0, higherX = 0, lowerX = 0, xForExtrapolation = 0 }) {
+ const slope = bigNumMinus(higherY, lowerY).div(bigNumMinus(higherX, lowerX))
+ const newTimeEstimate = slope.times(bigNumMinus(higherX, xForExtrapolation)).minus(newBigSigDig(higherY)).negated()
+
+ return newTimeEstimate.toNumber()
+}
+
+
+export function getNewXandTimeEstimate ({ xMousePos, chartXStart, chartWidth, gasPrices, estimatedTimes }) {
+ const chartMouseXPos = bigNumMinus(xMousePos, chartXStart)
+ const posPercentile = bigNumDiv(chartMouseXPos, chartWidth)
+
+ const currentPosValue = (bigNumMinus(gasPrices[gasPrices.length - 1], gasPrices[0]))
+ .times(newBigSigDig(posPercentile))
+ .plus(newBigSigDig(gasPrices[0]))
+ .toNumber()
+
+ const {
+ closestLowerValueIndex,
+ closestLowerValue,
+ closestHigherValueIndex,
+ closestHigherValue,
+ } = getAdjacentGasPrices({ gasPrices, priceToPosition: currentPosValue })
+
+ return !closestHigherValue || !closestLowerValue
+ ? {
+ currentPosValue: null,
+ newTimeEstimate: null,
+ }
+ : {
+ currentPosValue,
+ newTimeEstimate: extrapolateY({
+ higherY: estimatedTimes[closestHigherValueIndex],
+ lowerY: estimatedTimes[closestLowerValueIndex],
+ higherX: closestHigherValue,
+ lowerX: closestLowerValue,
+ xForExtrapolation: currentPosValue,
+ }),
+ }
+}
+
+export function hideDataUI (chart, dataNodeId) {
+ const overLayedCircle = d3.select(dataNodeId)
+ if (!overLayedCircle.empty()) {
+ overLayedCircle.remove()
+ }
+ d3.select('.c3-tooltip-container').style('display', 'none !important')
+ chart.internal.hideXGridFocus()
+}
+
+export function setTickPosition (axis, n, newPosition, secondNewPosition) {
+ const positionToShift = axis === 'y' ? 'x' : 'y'
+ const secondPositionToShift = axis === 'y' ? 'y' : 'x'
+ d3.select('#chart')
+ .select(`.c3-axis-${axis}`)
+ .selectAll('.tick')
+ .filter((d, i) => i === n)
+ .select('text')
+ .attr(positionToShift, 0)
+ .select('tspan')
+ .attr(positionToShift, newPosition)
+ .attr(secondPositionToShift, secondNewPosition || 0)
+ .style('visibility', 'visible')
+}
+
+export function appendOrUpdateCircle ({ data, itemIndex, cx, cy, cssId, appendOnly }) {
+ const circle = this.main
+ .select('.c3-selected-circles' + this.getTargetSelectorSuffix(data.id))
+ .selectAll(`.c3-selected-circle-${itemIndex}`)
+
+ if (appendOnly || circle.empty()) {
+ circle.data([data])
+ .enter().append('circle')
+ .attr('class', () => this.generateClass('c3-selected-circle', itemIndex))
+ .attr('id', cssId)
+ .attr('cx', cx)
+ .attr('cy', cy)
+ .attr('stroke', () => this.color(data))
+ .attr('r', 6)
+ } else {
+ circle.data([data])
+ .attr('cx', cx)
+ .attr('cy', cy)
+ }
+}
+
+export function setSelectedCircle ({
+ chart,
+ newPrice,
+ closestLowerValueIndex,
+ closestLowerValue,
+ closestHigherValueIndex,
+ closestHigherValue,
+}) {
+ const numberOfValues = chart.internal.data.xs.data1.length
+
+ const { x: lowerX, y: lowerY } = getCoordinateData(`.c3-circle-${closestLowerValueIndex}`)
+ let { x: higherX, y: higherY } = getCoordinateData(`.c3-circle-${closestHigherValueIndex}`)
+ let count = closestHigherValueIndex + 1
+
+ if (lowerX && higherX) {
+ while (lowerX === higherX) {
+ higherX = getCoordinateData(`.c3-circle-${count}`).x
+ higherY = getCoordinateData(`.c3-circle-${count}`).y
+ count++
+ }
+ }
+
+ const currentX = bigNumMinus(higherX, lowerX)
+ .times(bigNumMinus(newPrice, closestLowerValue))
+ .div(bigNumMinus(closestHigherValue, closestLowerValue))
+ .plus(newBigSigDig(lowerX))
+
+ const newTimeEstimate = extrapolateY({ higherY, lowerY, higherX, lowerX, xForExtrapolation: currentX })
+
+ chart.internal.selectPoint(
+ generateDataUIObj(currentX.toNumber(), numberOfValues, newTimeEstimate),
+ numberOfValues
+ )
+}
+
+
+export function generateChart (gasPrices, estimatedTimes, gasPricesMax, estimatedTimesMax) {
+ const gasPricesMaxPadded = gasPricesMax + 1
+ const chart = c3.generate({
+ size: {
+ height: 165,
+ },
+ transition: {
+ duration: 0,
+ },
+ padding: {left: 20, right: 15, top: 6, bottom: 10},
+ data: {
+ x: 'x',
+ columns: [
+ ['x', ...gasPrices],
+ ['data1', ...estimatedTimes],
+ ],
+ types: {
+ data1: 'area',
+ },
+ selection: {
+ enabled: false,
+ },
+ },
+ color: {
+ data1: '#259de5',
+ },
+ axis: {
+ x: {
+ min: gasPrices[0],
+ max: gasPricesMax,
+ tick: {
+ values: [Math.floor(gasPrices[0]), Math.ceil(gasPricesMax)],
+ outer: false,
+ format: function (val) { return val + ' GWEI' },
+ },
+ padding: {left: gasPricesMax / 50, right: gasPricesMax / 50},
+ label: {
+ text: 'Gas Price ($)',
+ position: 'outer-center',
+ },
+ },
+ y: {
+ padding: {top: 7, bottom: 7},
+ tick: {
+ values: [Math.floor(estimatedTimesMax * 0.05), Math.ceil(estimatedTimesMax * 0.97)],
+ outer: false,
+ },
+ label: {
+ text: 'Confirmation time (sec)',
+ position: 'outer-middle',
+ },
+ min: 0,
+ },
+ },
+ legend: {
+ show: false,
+ },
+ grid: {
+ x: {},
+ lines: {
+ front: false,
+ },
+ },
+ point: {
+ focus: {
+ expand: {
+ enabled: false,
+ r: 3.5,
+ },
+ },
+ },
+ tooltip: {
+ format: {
+ title: (v) => v.toPrecision(4),
+ },
+ contents: function (d) {
+ const titleFormat = this.config.tooltip_format_title
+ let text
+ d.forEach(el => {
+ if (el && (el.value || el.value === 0) && !text) {
+ text = "<table class='" + 'custom-tooltip' + "'>" + "<tr><th colspan='2'>" + titleFormat(el.x) + '</th></tr>'
+ }
+ })
+ return text + '</table>' + "<div class='tooltip-arrow'></div>"
+ },
+ position: function (data) {
+ if (d3.select('#overlayed-circle').empty()) {
+ return { top: -100, left: -100 }
+ }
+
+ const { x: circleX, y: circleY, width: circleWidth } = getCoordinateData('#overlayed-circle')
+ const { x: chartXStart, y: chartYStart } = getCoordinateData('.c3-chart')
+
+ // TODO: Confirm the below constants work with all data sets and screen sizes
+ const flipTooltip = circleY - circleWidth < chartYStart + 5
+
+ d3
+ .select('.tooltip-arrow')
+ .style('margin-top', flipTooltip ? '-16px' : '4px')
+
+ return {
+ top: bigNumMinus(circleY, chartYStart).minus(19).plus(flipTooltip ? circleWidth + 38 : 0).toNumber(),
+ left: bigNumMinus(circleX, chartXStart).plus(newBigSigDig(circleWidth)).minus(bigNumDiv(gasPricesMaxPadded, 50)).toNumber(),
+ }
+ },
+ show: true,
+ },
+ })
+
+ chart.internal.selectPoint = function (data, itemIndex = (data.index || 0)) {
+ const { x: chartXStart, y: chartYStart } = getCoordinateData('.c3-areas-data1')
+
+ d3.select('#set-circle').remove()
+
+ appendOrUpdateCircle.bind(this)({
+ data,
+ itemIndex,
+ cx: () => bigNumMinus(data.x, chartXStart).plus(11).toNumber(),
+ cy: () => bigNumMinus(data.value, chartYStart).plus(10).toNumber(),
+ cssId: 'set-circle',
+ appendOnly: true,
+ })
+ }
+
+ chart.internal.overlayPoint = function (data, itemIndex) {
+ appendOrUpdateCircle.bind(this)({
+ data,
+ itemIndex,
+ cx: this.circleX.bind(this),
+ cy: this.circleY.bind(this),
+ cssId: 'overlayed-circle',
+ })
+ }
+
+ chart.internal.showTooltip = function (selectedData, element) {
+ const dataToShow = selectedData.filter((d) => d && (d.value || d.value === 0))
+
+ if (dataToShow.length) {
+ this.tooltip.html(
+ this.config.tooltip_contents.call(this, selectedData, this.axis.getXAxisTickFormat(), this.getYFormat(), this.color)
+ ).style('display', 'flex')
+
+ // Get tooltip dimensions
+ const tWidth = this.tooltip.property('offsetWidth')
+ const tHeight = this.tooltip.property('offsetHeight')
+ const position = this.config.tooltip_position.call(this, dataToShow, tWidth, tHeight, element)
+ // Set tooltip
+ this.tooltip.style('top', position.top + 'px').style('left', position.left + 'px')
+ }
+ }
+
+ return chart
+}
diff --git a/ui/app/components/gas-customization/gas-price-chart/index.js b/ui/app/components/gas-customization/gas-price-chart/index.js
new file mode 100644
index 000000000..9895acb62
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-price-chart/index.js
@@ -0,0 +1 @@
+export { default } from './gas-price-chart.component'
diff --git a/ui/app/components/gas-customization/gas-price-chart/index.scss b/ui/app/components/gas-customization/gas-price-chart/index.scss
new file mode 100644
index 000000000..097543104
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-price-chart/index.scss
@@ -0,0 +1,132 @@
+.gas-price-chart {
+ display: flex;
+ position: relative;
+ justify-content: center;
+
+ &__root {
+ max-height: 154px;
+ max-width: 391px;
+ position: relative;
+ overflow: hidden;
+
+ @media screen and (max-width: $break-small) {
+ max-width: 326px;
+ }
+ }
+
+ .tick text, .c3-axis-x-label, .c3-axis-y-label {
+ font-family: Roboto;
+ font-style: normal;
+ font-weight: bold;
+ line-height: normal;
+ font-size: 8px;
+ text-align: center;
+ fill: #9A9CA6 !important;
+ }
+
+ .c3-tooltip-container {
+ display: flex;
+ justify-content: center !important;
+ align-items: flex-end !important;
+ }
+
+ .custom-tooltip {
+ background: rgba(0, 0, 0, 1);
+ box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
+ border-radius: 3px;
+ opacity: 1 !important;
+ height: 21px;
+ z-index: 1;
+ }
+
+ .tooltip-arrow {
+ background: black;
+ box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.5);
+ -webkit-transform: rotate(45deg);
+ transform: rotate(45deg);
+ opacity: 1 !important;
+ width: 9px;
+ height: 9px;
+ margin-top: 4px;
+ }
+
+ .custom-tooltip th {
+ font-family: Roboto;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ font-size: 10px;
+ text-align: center;
+ padding: 3px;
+ color: #FFFFFF;
+ }
+
+ .c3-circle {
+ visibility: hidden;
+ }
+
+ .c3-selected-circle, .c3-circle._expanded_ {
+ fill: #FFFFFF !important;
+ stroke-width: 2.4px !important;
+ stroke: #2d9fd9 !important;
+ /* visibility: visible; */
+ }
+
+ #set-circle {
+ fill: #313A5E !important;
+ stroke: #313A5E !important;
+ }
+
+ .c3-axis-x-label, .c3-axis-y-label {
+ font-weight: normal;
+ }
+
+ .tick text tspan {
+ visibility: hidden;
+ }
+
+ .c3-circle {
+ fill: #2d9fd9 !important;
+ }
+
+ .c3-line-data1 {
+ stroke: #2d9fd9 !important;
+ background: rgba(0,0,0,0) !important;
+ color: rgba(0,0,0,0) !important;
+ }
+
+ .c3 path {
+ fill: none;
+ }
+
+ .c3 path.c3-area-data1 {
+ opacity: 1;
+ fill: #e9edf1 !important;
+ }
+
+ .c3-xgrid-line line {
+ stroke: #B8B8B8 !important;
+ }
+
+ .c3-xgrid-focus {
+ stroke: #aaa;
+ }
+
+ .c3-axis-x .domain {
+ fill: none;
+ stroke: none;
+ }
+
+ .c3-axis-y .domain {
+ fill: none;
+ stroke: #C8CCD6;
+ }
+
+ .c3-event-rect {
+ cursor: pointer;
+ }
+}
+
+#chart {
+ background: #F8F9FB
+}
diff --git a/ui/app/components/gas-customization/gas-price-chart/tests/gas-price-chart.component.test.js b/ui/app/components/gas-customization/gas-price-chart/tests/gas-price-chart.component.test.js
new file mode 100644
index 000000000..74eddae42
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-price-chart/tests/gas-price-chart.component.test.js
@@ -0,0 +1,218 @@
+import React from 'react'
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+import sinon from 'sinon'
+import shallow from '../../../../../lib/shallow-with-context'
+import * as d3 from 'd3'
+
+function timeout (time) {
+ return new Promise((resolve, reject) => {
+ setTimeout(resolve, time)
+ })
+}
+
+const propsMethodSpies = {
+ updateCustomGasPrice: sinon.spy(),
+}
+
+const selectReturnSpies = {
+ empty: sinon.spy(),
+ remove: sinon.spy(),
+ style: sinon.spy(),
+ select: d3.select,
+ attr: sinon.spy(),
+ on: sinon.spy(),
+ datum: sinon.stub().returns({ x: 'mockX' }),
+}
+
+const mockSelectReturn = {
+ ...d3.select('div'),
+ node: () => ({
+ getBoundingClientRect: () => ({ x: 123, y: 321, width: 400 }),
+ }),
+ ...selectReturnSpies,
+}
+
+const gasPriceChartUtilsSpies = {
+ appendOrUpdateCircle: sinon.spy(),
+ generateChart: sinon.stub().returns({ mockChart: true }),
+ generateDataUIObj: sinon.spy(),
+ getAdjacentGasPrices: sinon.spy(),
+ getCoordinateData: sinon.stub().returns({ x: 'mockCoordinateX', width: 'mockWidth' }),
+ getNewXandTimeEstimate: sinon.spy(),
+ handleChartUpdate: sinon.spy(),
+ hideDataUI: sinon.spy(),
+ setSelectedCircle: sinon.spy(),
+ setTickPosition: sinon.spy(),
+ handleMouseMove: sinon.spy(),
+}
+
+const testProps = {
+ gasPrices: [1.5, 2.5, 4, 8],
+ estimatedTimes: [100, 80, 40, 10],
+ gasPricesMax: 9,
+ estimatedTimesMax: '100',
+ currentPrice: 6,
+ updateCustomGasPrice: propsMethodSpies.updateCustomGasPrice,
+}
+
+const GasPriceChart = proxyquire('../gas-price-chart.component.js', {
+ './gas-price-chart.utils.js': gasPriceChartUtilsSpies,
+ 'd3': {
+ ...d3,
+ select: function (...args) {
+ const result = d3.select(...args)
+ return result.empty()
+ ? mockSelectReturn
+ : result
+ },
+ event: {
+ clientX: 'mockClientX',
+ },
+ },
+}).default
+
+sinon.spy(GasPriceChart.prototype, 'renderChart')
+
+describe('GasPriceChart Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<GasPriceChart {...testProps} />)
+ })
+
+ describe('render()', () => {
+ it('should render', () => {
+ assert(wrapper.hasClass('gas-price-chart'))
+ })
+
+ it('should render the chart div', () => {
+ assert(wrapper.childAt(0).hasClass('gas-price-chart__root'))
+ assert.equal(wrapper.childAt(0).props().id, 'chart')
+ })
+ })
+
+ describe('componentDidMount', () => {
+ it('should call this.renderChart with the components props', () => {
+ assert(GasPriceChart.prototype.renderChart.callCount, 1)
+ wrapper.instance().componentDidMount()
+ assert(GasPriceChart.prototype.renderChart.callCount, 2)
+ assert.deepEqual(GasPriceChart.prototype.renderChart.getCall(1).args, [{...testProps}])
+ })
+ })
+
+ describe('componentDidUpdate', () => {
+ it('should call handleChartUpdate if props.currentPrice has changed', () => {
+ gasPriceChartUtilsSpies.handleChartUpdate.resetHistory()
+ wrapper.instance().componentDidUpdate({ currentPrice: 7 })
+ assert.equal(gasPriceChartUtilsSpies.handleChartUpdate.callCount, 1)
+ })
+
+ it('should call handleChartUpdate with the correct props', () => {
+ gasPriceChartUtilsSpies.handleChartUpdate.resetHistory()
+ wrapper.instance().componentDidUpdate({ currentPrice: 7 })
+ assert.deepEqual(gasPriceChartUtilsSpies.handleChartUpdate.getCall(0).args, [{
+ chart: { mockChart: true },
+ gasPrices: [1.5, 2.5, 4, 8],
+ newPrice: 6,
+ cssId: '#set-circle',
+ }])
+ })
+
+ it('should not call handleChartUpdate if props.currentPrice has not changed', () => {
+ gasPriceChartUtilsSpies.handleChartUpdate.resetHistory()
+ wrapper.instance().componentDidUpdate({ currentPrice: 6 })
+ assert.equal(gasPriceChartUtilsSpies.handleChartUpdate.callCount, 0)
+ })
+ })
+
+ describe('renderChart', () => {
+ it('should call setTickPosition 4 times, with the expected props', async () => {
+ await timeout(0)
+ gasPriceChartUtilsSpies.setTickPosition.resetHistory()
+ assert.equal(gasPriceChartUtilsSpies.setTickPosition.callCount, 0)
+ wrapper.instance().renderChart(testProps)
+ await timeout(0)
+ assert.equal(gasPriceChartUtilsSpies.setTickPosition.callCount, 4)
+ assert.deepEqual(gasPriceChartUtilsSpies.setTickPosition.getCall(0).args, ['y', 0, -5, 8])
+ assert.deepEqual(gasPriceChartUtilsSpies.setTickPosition.getCall(1).args, ['y', 1, -3, -5])
+ assert.deepEqual(gasPriceChartUtilsSpies.setTickPosition.getCall(2).args, ['x', 0, 3])
+ assert.deepEqual(gasPriceChartUtilsSpies.setTickPosition.getCall(3).args, ['x', 1, 3, -8])
+ })
+
+ it('should call handleChartUpdate with the correct props', async () => {
+ await timeout(0)
+ gasPriceChartUtilsSpies.handleChartUpdate.resetHistory()
+ wrapper.instance().renderChart(testProps)
+ await timeout(0)
+ assert.deepEqual(gasPriceChartUtilsSpies.handleChartUpdate.getCall(0).args, [{
+ chart: { mockChart: true },
+ gasPrices: [1.5, 2.5, 4, 8],
+ newPrice: 6,
+ cssId: '#set-circle',
+ }])
+ })
+
+ it('should add three events to the chart', async () => {
+ await timeout(0)
+ selectReturnSpies.on.resetHistory()
+ assert.equal(selectReturnSpies.on.callCount, 0)
+ wrapper.instance().renderChart(testProps)
+ await timeout(0)
+ assert.equal(selectReturnSpies.on.callCount, 3)
+
+ const firstOnEventArgs = selectReturnSpies.on.getCall(0).args
+ assert.equal(firstOnEventArgs[0], 'mouseout')
+ const secondOnEventArgs = selectReturnSpies.on.getCall(1).args
+ assert.equal(secondOnEventArgs[0], 'click')
+ const thirdOnEventArgs = selectReturnSpies.on.getCall(2).args
+ assert.equal(thirdOnEventArgs[0], 'mousemove')
+ })
+
+ it('should hide the data UI on mouseout', async () => {
+ await timeout(0)
+ selectReturnSpies.on.resetHistory()
+ wrapper.instance().renderChart(testProps)
+ gasPriceChartUtilsSpies.hideDataUI.resetHistory()
+ await timeout(0)
+ const mouseoutEventArgs = selectReturnSpies.on.getCall(0).args
+ assert.equal(gasPriceChartUtilsSpies.hideDataUI.callCount, 0)
+ mouseoutEventArgs[1]()
+ assert.equal(gasPriceChartUtilsSpies.hideDataUI.callCount, 1)
+ assert.deepEqual(gasPriceChartUtilsSpies.hideDataUI.getCall(0).args, [{ mockChart: true }, '#overlayed-circle'])
+ })
+
+ it('should updateCustomGasPrice on click', async () => {
+ await timeout(0)
+ selectReturnSpies.on.resetHistory()
+ wrapper.instance().renderChart(testProps)
+ propsMethodSpies.updateCustomGasPrice.resetHistory()
+ await timeout(0)
+ const mouseoutEventArgs = selectReturnSpies.on.getCall(1).args
+ assert.equal(propsMethodSpies.updateCustomGasPrice.callCount, 0)
+ mouseoutEventArgs[1]()
+ assert.equal(propsMethodSpies.updateCustomGasPrice.callCount, 1)
+ assert.equal(propsMethodSpies.updateCustomGasPrice.getCall(0).args[0], 'mockX')
+ })
+
+ it('should handle mousemove', async () => {
+ await timeout(0)
+ selectReturnSpies.on.resetHistory()
+ wrapper.instance().renderChart(testProps)
+ gasPriceChartUtilsSpies.handleMouseMove.resetHistory()
+ await timeout(0)
+ const mouseoutEventArgs = selectReturnSpies.on.getCall(2).args
+ assert.equal(gasPriceChartUtilsSpies.handleMouseMove.callCount, 0)
+ mouseoutEventArgs[1]()
+ assert.equal(gasPriceChartUtilsSpies.handleMouseMove.callCount, 1)
+ assert.deepEqual(gasPriceChartUtilsSpies.handleMouseMove.getCall(0).args, [{
+ xMousePos: 'mockClientX',
+ chartXStart: 'mockCoordinateX',
+ chartWidth: 'mockWidth',
+ gasPrices: testProps.gasPrices,
+ estimatedTimes: testProps.estimatedTimes,
+ chart: { mockChart: true },
+ }])
+ })
+ })
+})
diff --git a/ui/app/components/gas-customization/gas-slider/gas-slider.component.js b/ui/app/components/gas-customization/gas-slider/gas-slider.component.js
new file mode 100644
index 000000000..5836e7dfc
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-slider/gas-slider.component.js
@@ -0,0 +1,48 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+
+export default class AdvancedTabContent extends Component {
+ static propTypes = {
+ onChange: PropTypes.func,
+ lowLabel: PropTypes.string,
+ highLabel: PropTypes.string,
+ value: PropTypes.number,
+ step: PropTypes.number,
+ max: PropTypes.number,
+ min: PropTypes.number,
+ }
+
+ render () {
+ const {
+ onChange,
+ lowLabel,
+ highLabel,
+ value,
+ step,
+ max,
+ min,
+ } = this.props
+
+ return (
+ <div className="gas-slider">
+ <input
+ className="gas-slider__input"
+ type="range"
+ step={step}
+ max={max}
+ min={min}
+ value={value}
+ id="gasSlider"
+ onChange={event => onChange(event.target.value)}
+ />
+ <div className="gas-slider__bar">
+ <div className="gas-slider__colored"/>
+ </div>
+ <div className="gas-slider__labels">
+ <span>{lowLabel}</span>
+ <span>{highLabel}</span>
+ </div>
+ </div>
+ )
+ }
+}
diff --git a/ui/app/components/gas-customization/gas-slider/index.js b/ui/app/components/gas-customization/gas-slider/index.js
new file mode 100644
index 000000000..f1752c93f
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-slider/index.js
@@ -0,0 +1 @@
+export { default } from './gas-slider.component'
diff --git a/ui/app/components/gas-customization/gas-slider/index.scss b/ui/app/components/gas-customization/gas-slider/index.scss
new file mode 100644
index 000000000..e6c734367
--- /dev/null
+++ b/ui/app/components/gas-customization/gas-slider/index.scss
@@ -0,0 +1,54 @@
+.gas-slider {
+ position: relative;
+ width: 322px;
+
+ &__input {
+ width: 322px;
+ margin-left: -2px;
+ z-index: 2;
+ }
+
+ input[type=range] {
+ -webkit-appearance: none !important;
+ }
+
+ input[type=range]::-webkit-slider-thumb {
+ -webkit-appearance: none !important;
+ height: 34px;
+ width: 34px;
+ background-color: $curious-blue;
+ box-shadow: 0 2px 4px 0 rgba(0,0,0,0.08);
+ border-radius: 50%;
+ position: relative;
+ z-index: 10;
+ }
+
+ &__bar {
+ height: 6px;
+ width: 322px;
+ background: $alto;
+ display: flex;
+ justify-content: space-between;
+ position: absolute;
+ top: 16px;
+ z-index: 0;
+ border-radius: 4px;
+ }
+
+ &__colored {
+ height: 6px;
+ border-radius: 4px;
+ margin-left: 102px;
+ width: 322px;
+ z-index: 1;
+ background-color: $blizzard-blue;
+ }
+
+ &__labels {
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+ margin-top: -6px;
+ color: $mid-gray;
+ }
+} \ No newline at end of file
diff --git a/ui/app/components/gas-customization/gas.selectors.js b/ui/app/components/gas-customization/gas.selectors.js
new file mode 100644
index 000000000..89374b5f1
--- /dev/null
+++ b/ui/app/components/gas-customization/gas.selectors.js
@@ -0,0 +1,14 @@
+const selectors = {
+ getCurrentBlockTime,
+ getBasicGasEstimateLoadingStatus,
+}
+
+module.exports = selectors
+
+function getCurrentBlockTime (state) {
+ return state.gas.currentBlockTime
+}
+
+function getBasicGasEstimateLoadingStatus (state) {
+ return state.gas.basicEstimateIsLoading
+}
diff --git a/ui/app/components/gas-customization/index.scss b/ui/app/components/gas-customization/index.scss
new file mode 100644
index 000000000..e99d4e57f
--- /dev/null
+++ b/ui/app/components/gas-customization/index.scss
@@ -0,0 +1,5 @@
+@import './gas-slider/index';
+
+@import './gas-modal-page-container/index';
+
+@import './gas-price-chart/index';
diff --git a/ui/app/components/index.scss b/ui/app/components/index.scss
index f901aed7d..78c1216f7 100644
--- a/ui/app/components/index.scss
+++ b/ui/app/components/index.scss
@@ -63,3 +63,13 @@
@import './sidebars/index';
@import './unit-input/index';
+
+@import './gas-customization/gas-modal-page-container/index';
+
+@import './gas-customization/gas-modal-page-container/index';
+
+@import './gas-customization/gas-modal-page-container/index';
+
+@import './gas-customization/index';
+
+@import './gas-customization/gas-price-button-group/index';
diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js b/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js
index eede8b1ee..10931a001 100644
--- a/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js
+++ b/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js
@@ -28,31 +28,29 @@ const mapStateToProps = (state, ownProps) => {
transactionId,
transactionStatus,
originalGasPrice,
+ defaultNewGasPrice,
newGasFee,
}
}
const mapDispatchToProps = dispatch => {
return {
- createCancelTransaction: txId => dispatch(createCancelTransaction(txId)),
+ createCancelTransaction: (txId, customGasPrice) => {
+ return dispatch(createCancelTransaction(txId, customGasPrice))
+ },
showTransactionConfirmedModal: () => dispatch(showModal({ name: 'TRANSACTION_CONFIRMED' })),
}
}
const mergeProps = (stateProps, dispatchProps, ownProps) => {
- const { transactionId, ...restStateProps } = stateProps
- const {
- createCancelTransaction: dispatchCreateCancelTransaction,
- ...restDispatchProps
- } = dispatchProps
+ const { transactionId, defaultNewGasPrice, ...restStateProps } = stateProps
+ const { createCancelTransaction, ...restDispatchProps } = dispatchProps
return {
...restStateProps,
...restDispatchProps,
...ownProps,
- createCancelTransaction: newGasPrice => {
- return dispatchCreateCancelTransaction(transactionId, newGasPrice)
- },
+ createCancelTransaction: () => createCancelTransaction(transactionId, defaultNewGasPrice),
}
}
diff --git a/ui/app/components/modals/modal.js b/ui/app/components/modals/modal.js
index 5aff4f5e1..0a603db4e 100644
--- a/ui/app/components/modals/modal.js
+++ b/ui/app/components/modals/modal.js
@@ -4,6 +4,7 @@ const inherits = require('util').inherits
const connect = require('react-redux').connect
const FadeModal = require('boron').FadeModal
const actions = require('../../actions')
+const { resetCustomData: resetCustomGasData } = require('../../ducks/gas.duck')
const isMobileView = require('../../../lib/is-mobile-view')
const { getEnvironmentType } = require('../../../../app/scripts/lib/util')
const { ENVIRONMENT_TYPE_POPUP } = require('../../../../app/scripts/lib/enums')
@@ -17,18 +18,17 @@ const ExportPrivateKeyModal = require('./export-private-key-modal')
const NewAccountModal = require('./new-account-modal')
const ShapeshiftDepositTxModal = require('./shapeshift-deposit-tx-modal.js')
const HideTokenConfirmationModal = require('./hide-token-confirmation-modal')
-const CustomizeGasModal = require('../customize-gas-modal')
const NotifcationModal = require('./notification-modal')
const QRScanner = require('./qr-scanner')
import ConfirmRemoveAccount from './confirm-remove-account'
import ConfirmResetAccount from './confirm-reset-account'
import TransactionConfirmed from './transaction-confirmed'
-import ConfirmCustomizeGasModal from './customize-gas'
import CancelTransaction from './cancel-transaction'
import WelcomeBeta from './welcome-beta'
import RejectTransactions from './reject-transactions'
import ClearApprovedOrigins from './clear-approved-origins'
+import ConfirmCustomizeGasModal from '../gas-customization/gas-modal-page-container'
const modalContainerBaseStyle = {
transform: 'translate3d(-50%, 0, 0px)',
@@ -295,7 +295,7 @@ const MODALS = {
CUSTOMIZE_GAS: {
contents: [
- h(CustomizeGasModal),
+ h(ConfirmCustomizeGasModal),
],
mobileModalStyle: {
width: '100vw',
@@ -307,35 +307,20 @@ const MODALS = {
margin: '0 auto',
},
laptopModalStyle: {
- width: '720px',
- height: '377px',
+ width: 'auto',
+ height: '0px',
top: '80px',
+ left: '0px',
transform: 'none',
- left: '0',
- right: '0',
margin: '0 auto',
+ position: 'relative',
},
- },
-
- CONFIRM_CUSTOMIZE_GAS: {
- contents: h(ConfirmCustomizeGasModal),
- mobileModalStyle: {
- width: '100vw',
- height: '100vh',
- top: '0',
- transform: 'none',
- left: '0',
- right: '0',
- margin: '0 auto',
+ contentStyle: {
+ borderRadius: '8px',
},
- laptopModalStyle: {
- width: '720px',
- height: '377px',
- top: '80px',
- transform: 'none',
- left: '0',
- right: '0',
- margin: '0 auto',
+ customOnHideOpts: {
+ action: resetCustomGasData,
+ args: [],
},
},
@@ -412,8 +397,11 @@ function mapStateToProps (state) {
function mapDispatchToProps (dispatch) {
return {
- hideModal: () => {
+ hideModal: (customOnHideOpts) => {
dispatch(actions.hideModal())
+ if (customOnHideOpts && customOnHideOpts.action) {
+ dispatch(customOnHideOpts.action(...customOnHideOpts.args))
+ }
},
hideWarning: () => {
dispatch(actions.hideWarning())
@@ -445,7 +433,7 @@ Modal.prototype.render = function () {
if (modal.onHide) {
modal.onHide(this.props)
}
- this.onHide()
+ this.onHide(modal.customOnHideOpts)
},
ref: (ref) => {
this.modalRef = ref
@@ -467,11 +455,11 @@ Modal.prototype.componentWillReceiveProps = function (nextProps) {
}
}
-Modal.prototype.onHide = function () {
+Modal.prototype.onHide = function (customOnHideOpts) {
if (this.props.onHideCallback) {
this.props.onHideCallback()
}
- this.props.hideModal()
+ this.props.hideModal(customOnHideOpts)
}
Modal.prototype.hide = function () {
diff --git a/ui/app/components/page-container/index.scss b/ui/app/components/page-container/index.scss
index ba1215e84..6fc97820a 100644
--- a/ui/app/components/page-container/index.scss
+++ b/ui/app/components/page-container/index.scss
@@ -6,6 +6,7 @@
display: flex;
flex-flow: column;
border-radius: 8px;
+ overflow-y: auto;
&__header {
display: flex;
@@ -194,10 +195,10 @@
.page-container {
height: 100%;
width: 100%;
- overflow-y: auto;
background-color: $white;
border-radius: 0;
flex: 1;
+ overflow-y: auto;
}
}
diff --git a/ui/app/components/page-container/page-container-footer/page-container-footer.component.js b/ui/app/components/page-container/page-container-footer/page-container-footer.component.js
index 773fe1f56..85b16cefe 100644
--- a/ui/app/components/page-container/page-container-footer/page-container-footer.component.js
+++ b/ui/app/components/page-container/page-container-footer/page-container-footer.component.js
@@ -12,6 +12,7 @@ export default class PageContainerFooter extends Component {
submitText: PropTypes.string,
disabled: PropTypes.bool,
submitButtonType: PropTypes.string,
+ hideCancel: PropTypes.bool,
}
static contextTypes = {
@@ -27,20 +28,21 @@ export default class PageContainerFooter extends Component {
submitText,
disabled,
submitButtonType,
+ hideCancel,
} = this.props
return (
<div className="page-container__footer">
<header>
- <Button
+ {!hideCancel && <Button
type="default"
large
className="page-container__footer-button"
onClick={e => onCancel(e)}
>
{ cancelText || this.context.t('cancel') }
- </Button>
+ </Button>}
<Button
type={submitButtonType || 'primary'}
diff --git a/ui/app/components/page-container/page-container-header/page-container-header.component.js b/ui/app/components/page-container/page-container-header/page-container-header.component.js
index a8458604e..08f9c7544 100644
--- a/ui/app/components/page-container/page-container-header/page-container-header.component.js
+++ b/ui/app/components/page-container/page-container-header/page-container-header.component.js
@@ -12,6 +12,7 @@ export default class PageContainerHeader extends Component {
backButtonStyles: PropTypes.object,
backButtonString: PropTypes.string,
tabs: PropTypes.node,
+ headerCloseText: PropTypes.string,
}
renderTabs () {
@@ -41,7 +42,7 @@ export default class PageContainerHeader extends Component {
}
render () {
- const { title, subtitle, onClose, tabs } = this.props
+ const { title, subtitle, onClose, tabs, headerCloseText } = this.props
return (
<div className={
@@ -66,10 +67,12 @@ export default class PageContainerHeader extends Component {
}
{
- onClose && <div
- className="page-container__header-close"
- onClick={() => onClose()}
- />
+ onClose && headerCloseText
+ ? <div className="page-container__header-close-text" onClick={() => onClose()}>{ headerCloseText }</div>
+ : onClose && <div
+ className="page-container__header-close"
+ onClick={() => onClose()}
+ />
}
{ this.renderTabs() }
diff --git a/ui/app/components/page-container/page-container.component.js b/ui/app/components/page-container/page-container.component.js
index 3a2274a29..45dfff517 100644
--- a/ui/app/components/page-container/page-container.component.js
+++ b/ui/app/components/page-container/page-container.component.js
@@ -9,6 +9,7 @@ export default class PageContainer extends PureComponent {
// PageContainerHeader props
backButtonString: PropTypes.string,
backButtonStyles: PropTypes.object,
+ headerCloseText: PropTypes.string,
onBackButtonClick: PropTypes.func,
onClose: PropTypes.func,
showBackButton: PropTypes.bool,
@@ -22,6 +23,7 @@ export default class PageContainer extends PureComponent {
// PageContainerFooter props
cancelText: PropTypes.string,
disabled: PropTypes.bool,
+ hideCancel: PropTypes.bool,
onCancel: PropTypes.func,
onSubmit: PropTypes.func,
submitText: PropTypes.string,
@@ -58,7 +60,8 @@ export default class PageContainer extends PureComponent {
renderActiveTabContent () {
const { tabsComponent } = this.props
- const { children } = tabsComponent.props
+ let { children } = tabsComponent.props
+ children = children.filter(child => child)
const { activeTabIndex } = this.state
return children[activeTabIndex]
@@ -92,6 +95,8 @@ export default class PageContainer extends PureComponent {
onSubmit,
submitText,
disabled,
+ headerCloseText,
+ hideCancel,
} = this.props
return (
@@ -105,17 +110,21 @@ export default class PageContainer extends PureComponent {
backButtonStyles={backButtonStyles}
backButtonString={backButtonString}
tabs={this.renderTabs()}
+ headerCloseText={headerCloseText}
/>
- <div className="page-container__content">
- { this.renderContent() }
+ <div className="page-container__bottom">
+ <div className="page-container__content">
+ { this.renderContent() }
+ </div>
+ <PageContainerFooter
+ onCancel={onCancel}
+ cancelText={cancelText}
+ hideCancel={hideCancel}
+ onSubmit={onSubmit}
+ submitText={submitText}
+ disabled={disabled}
+ />
</div>
- <PageContainerFooter
- onCancel={onCancel}
- cancelText={cancelText}
- onSubmit={onSubmit}
- submitText={submitText}
- disabled={disabled}
- />
</div>
)
}
diff --git a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js
index e3abde233..6bc415781 100644
--- a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js
+++ b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js
@@ -426,7 +426,7 @@ export default class ConfirmTransactionBase extends Component {
toName={toName}
toAddress={toAddress}
showEdit={onEdit && !isTxReprice}
- action={action || name || this.context.t('unknownFunction')}
+ action={action || name || this.context.t('contractInteraction')}
title={title}
titleComponent={this.renderTitleComponent()}
subtitle={subtitle}
diff --git a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js
index 626143ac7..1e2270432 100644
--- a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js
+++ b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js
@@ -29,7 +29,7 @@ const casedContractMap = Object.keys(contractMap).reduce((acc, base) => {
const mapStateToProps = (state, props) => {
const { toAddress: propsToAddress } = props
- const { confirmTransaction, metamask } = state
+ const { confirmTransaction, metamask, gas } = state
const {
ethTransactionAmount,
ethTransactionFee,
@@ -60,6 +60,12 @@ const mapStateToProps = (state, props) => {
unapprovedTxs,
} = metamask
const assetImage = assetImages[txParamsToAddress]
+
+ const {
+ customGasLimit,
+ customGasPrice,
+ } = gas
+
const { balance } = accounts[selectedAddress]
const { name: fromName } = identities[selectedAddress]
const toAddress = propsToAddress || txParamsToAddress
@@ -106,6 +112,10 @@ const mapStateToProps = (state, props) => {
unapprovedTxs,
unapprovedTxCount,
currentNetworkUnapprovedTxs,
+ customGas: {
+ gasLimit: customGasLimit || txData.gasPrice,
+ gasPrice: customGasPrice || txData.gasLimit,
+ },
}
}
@@ -117,7 +127,7 @@ const mapDispatchToProps = dispatch => {
return dispatch(showModal({ name: 'TRANSACTION_CONFIRMED', onSubmit }))
},
showCustomizeGasModal: ({ txData, onSubmit, validate }) => {
- return dispatch(showModal({ name: 'CONFIRM_CUSTOMIZE_GAS', txData, onSubmit, validate }))
+ return dispatch(showModal({ name: 'CUSTOMIZE_GAS', txData, onSubmit, validate }))
},
updateGasAndCalculate: ({ gasLimit, gasPrice }) => {
return dispatch(updateGasAndCalculate({ gasLimit, gasPrice }))
@@ -192,7 +202,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
...ownProps,
showCustomizeGasModal: () => dispatchShowCustomizeGasModal({
txData,
- onSubmit: txData => dispatchUpdateGasAndCalculate(txData),
+ onSubmit: customGas => dispatchUpdateGasAndCalculate(customGas),
validate: validateEditGas,
}),
cancelAllTransactions: () => dispatchCancelAllTransactions(valuesFor(unapprovedTxs)),
diff --git a/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js b/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js
index 3ac656d73..2e460f377 100644
--- a/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js
+++ b/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js
@@ -32,6 +32,7 @@ export default class ConfirmTransaction extends Component {
setTransactionToConfirm: PropTypes.func,
confirmTransaction: PropTypes.object,
clearConfirmTransaction: PropTypes.func,
+ fetchBasicGasAndTimeEstimates: PropTypes.func,
}
getParamsTransactionId () {
@@ -45,6 +46,7 @@ export default class ConfirmTransaction extends Component {
send = {},
history,
confirmTransaction: { txData: { id: transactionId } = {} },
+ fetchBasicGasAndTimeEstimates,
} = this.props
if (!totalUnapprovedCount && !send.to) {
@@ -53,6 +55,7 @@ export default class ConfirmTransaction extends Component {
}
if (!transactionId) {
+ fetchBasicGasAndTimeEstimates()
this.setTransactionToConfirm()
}
}
diff --git a/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js b/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js
index 1bc2f1efb..46342dc76 100644
--- a/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js
+++ b/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js
@@ -5,6 +5,9 @@ import {
setTransactionToConfirm,
clearConfirmTransaction,
} from '../../../ducks/confirm-transaction.duck'
+import {
+ fetchBasicGasAndTimeEstimates,
+} from '../../../ducks/gas.duck'
import ConfirmTransaction from './confirm-transaction.component'
import { getTotalUnapprovedCount } from '../../../selectors'
import { unconfirmedTransactionsListSelector } from '../../../selectors/confirm-transaction'
@@ -24,6 +27,7 @@ const mapDispatchToProps = dispatch => {
return {
setTransactionToConfirm: transactionId => dispatch(setTransactionToConfirm(transactionId)),
clearConfirmTransaction: () => dispatch(clearConfirmTransaction()),
+ fetchBasicGasAndTimeEstimates: () => dispatch(fetchBasicGasAndTimeEstimates()),
}
}
diff --git a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js
index 9bbb67506..b667aa037 100644
--- a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js
+++ b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js
@@ -11,7 +11,7 @@ export default class GasFeeDisplay extends Component {
convertedCurrency: PropTypes.string,
gasLoadingError: PropTypes.bool,
gasTotal: PropTypes.string,
- onClick: PropTypes.func,
+ onReset: PropTypes.func,
};
static contextTypes = {
@@ -19,7 +19,7 @@ export default class GasFeeDisplay extends Component {
};
render () {
- const { gasTotal, onClick, gasLoadingError } = this.props
+ const { gasTotal, gasLoadingError, onReset } = this.props
return (
<div className="send-v2__gas-fee-display">
@@ -46,11 +46,10 @@ export default class GasFeeDisplay extends Component {
</div>
}
<button
- className="sliders-icon-container"
- onClick={onClick}
- disabled={!gasTotal && !gasLoadingError}
+ className="gas-fee-reset"
+ onClick={onReset}
>
- <i className="fa fa-sliders sliders-icon" />
+ { this.context.t('reset') }
</button>
</div>
)
diff --git a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js
index 9ff01493a..cb4180508 100644
--- a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js
+++ b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js
@@ -8,18 +8,20 @@ import sinon from 'sinon'
const propsMethodSpies = {
showCustomizeGasModal: sinon.spy(),
+ onReset: sinon.spy(),
}
-describe('SendGasRow Component', function () {
+describe('GasFeeDisplay Component', function () {
let wrapper
beforeEach(() => {
wrapper = shallow(<GasFeeDisplay
conversionRate={20}
gasTotal={'mockGasTotal'}
- onClick={propsMethodSpies.showCustomizeGasModal}
primaryCurrency={'mockPrimaryCurrency'}
convertedCurrency={'mockConvertedCurrency'}
+ showGasButtonGroup={propsMethodSpies.showCustomizeGasModal}
+ onReset={propsMethodSpies.onReset}
/>, {context: {t: str => str + '_t'}})
})
@@ -41,13 +43,19 @@ describe('SendGasRow Component', function () {
assert.equal(value, 'mockGasTotal')
})
- it('should render the Button with the correct props', () => {
+ it('should render the reset button with the correct props', () => {
const {
onClick,
+ className,
} = wrapper.find('button').props()
- assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 0)
+ assert.equal(className, 'gas-fee-reset')
+ assert.equal(propsMethodSpies.onReset.callCount, 0)
onClick()
- assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 1)
+ assert.equal(propsMethodSpies.onReset.callCount, 1)
+ })
+
+ it('should render the reset button with the correct text', () => {
+ assert.equal(wrapper.find('button').text(), 'reset_t')
})
})
})
diff --git a/ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js b/ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js
index 6ad4499ff..8d305dd4f 100644
--- a/ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js
+++ b/ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js
@@ -2,6 +2,7 @@ import React, { Component } from 'react'
import PropTypes from 'prop-types'
import SendRowWrapper from '../send-row-wrapper/'
import GasFeeDisplay from './gas-fee-display/gas-fee-display.component'
+import GasPriceButtonGroup from '../../../gas-customization/gas-price-button-group'
export default class SendGasRow extends Component {
@@ -12,6 +13,9 @@ export default class SendGasRow extends Component {
gasLoadingError: PropTypes.bool,
gasTotal: PropTypes.string,
showCustomizeGasModal: PropTypes.func,
+ gasPriceButtonGroupProps: PropTypes.object,
+ gasButtonGroupShown: PropTypes.bool,
+ resetGasButtons: PropTypes.func,
}
static contextTypes = {
@@ -26,21 +30,37 @@ export default class SendGasRow extends Component {
gasTotal,
gasFeeError,
showCustomizeGasModal,
+ gasPriceButtonGroupProps,
+ gasButtonGroupShown,
+ resetGasButtons,
} = this.props
return (
<SendRowWrapper
- label={`${this.context.t('gasFee')}:`}
+ label={`${this.context.t('transactionFee')}:`}
showError={gasFeeError}
errorType={'gasFee'}
>
- <GasFeeDisplay
- conversionRate={conversionRate}
- convertedCurrency={convertedCurrency}
- gasLoadingError={gasLoadingError}
- gasTotal={gasTotal}
- onClick={() => showCustomizeGasModal()}
- />
+ {gasButtonGroupShown
+ ? <div>
+ <GasPriceButtonGroup
+ className="gas-price-button-group--small"
+ showCheck={false}
+ {...gasPriceButtonGroupProps}
+ />
+ <div className="advanced-gas-options-btn" onClick={() => showCustomizeGasModal()}>
+ { this.context.t('advancedOptions') }
+ </div>
+ </div>
+ : <GasFeeDisplay
+ conversionRate={conversionRate}
+ convertedCurrency={convertedCurrency}
+ gasLoadingError={gasLoadingError}
+ gasTotal={gasTotal}
+ onReset={resetGasButtons}
+ onClick={() => showCustomizeGasModal()}
+ />}
+
</SendRowWrapper>
)
}
diff --git a/ui/app/components/send/send-content/send-gas-row/send-gas-row.container.js b/ui/app/components/send/send-content/send-gas-row/send-gas-row.container.js
index e44fe4ef1..977f8ab3c 100644
--- a/ui/app/components/send/send-content/send-gas-row/send-gas-row.container.js
+++ b/ui/app/components/send/send-content/send-gas-row/send-gas-row.container.js
@@ -3,25 +3,76 @@ import {
getConversionRate,
getCurrentCurrency,
getGasTotal,
+ getGasPrice,
} from '../../send.selectors.js'
-import { getGasLoadingError, gasFeeIsInError } from './send-gas-row.selectors.js'
-import { showModal } from '../../../../actions'
+import {
+ getBasicGasEstimateLoadingStatus,
+ getRenderableEstimateDataForSmallButtonsFromGWEI,
+ getDefaultActiveButtonIndex,
+} from '../../../../selectors/custom-gas'
+import {
+ showGasButtonGroup,
+} from '../../../../ducks/send.duck'
+import {
+ resetCustomData,
+} from '../../../../ducks/gas.duck'
+import { getGasLoadingError, gasFeeIsInError, getGasButtonGroupShown } from './send-gas-row.selectors.js'
+import { showModal, setGasPrice } from '../../../../actions'
import SendGasRow from './send-gas-row.component'
-export default connect(mapStateToProps, mapDispatchToProps)(SendGasRow)
+export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(SendGasRow)
function mapStateToProps (state) {
+ const gasButtonInfo = getRenderableEstimateDataForSmallButtonsFromGWEI(state)
+ const activeButtonIndex = getDefaultActiveButtonIndex(gasButtonInfo, getGasPrice(state))
+
return {
conversionRate: getConversionRate(state),
convertedCurrency: getCurrentCurrency(state),
gasTotal: getGasTotal(state),
gasFeeError: gasFeeIsInError(state),
gasLoadingError: getGasLoadingError(state),
+ gasPriceButtonGroupProps: {
+ buttonDataLoading: getBasicGasEstimateLoadingStatus(state),
+ defaultActiveButtonIndex: 1,
+ newActiveButtonIndex: activeButtonIndex > -1 ? activeButtonIndex : null,
+ gasButtonInfo,
+ },
+ gasButtonGroupShown: getGasButtonGroupShown(state),
}
}
function mapDispatchToProps (dispatch) {
return {
- showCustomizeGasModal: () => dispatch(showModal({ name: 'CUSTOMIZE_GAS' })),
+ showCustomizeGasModal: () => dispatch(showModal({ name: 'CUSTOMIZE_GAS', hideBasic: true })),
+ setGasPrice: newPrice => dispatch(setGasPrice(newPrice)),
+ showGasButtonGroup: () => dispatch(showGasButtonGroup()),
+ resetCustomData: () => dispatch(resetCustomData()),
+ }
+}
+
+function mergeProps (stateProps, dispatchProps, ownProps) {
+ const { gasPriceButtonGroupProps } = stateProps
+ const { gasButtonInfo } = gasPriceButtonGroupProps
+ const {
+ setGasPrice: dispatchSetGasPrice,
+ showGasButtonGroup: dispatchShowGasButtonGroup,
+ resetCustomData: dispatchResetCustomData,
+ ...otherDispatchProps
+ } = dispatchProps
+
+ return {
+ ...stateProps,
+ ...otherDispatchProps,
+ ...ownProps,
+ gasPriceButtonGroupProps: {
+ ...gasPriceButtonGroupProps,
+ handleGasPriceSelection: dispatchSetGasPrice,
+ },
+ resetGasButtons: () => {
+ dispatchResetCustomData()
+ dispatchSetGasPrice(gasButtonInfo[1].priceInHexWei)
+ dispatchShowGasButtonGroup()
+ },
}
}
diff --git a/ui/app/components/send/send-content/send-gas-row/send-gas-row.selectors.js b/ui/app/components/send/send-content/send-gas-row/send-gas-row.selectors.js
index 96f6293c2..79c838543 100644
--- a/ui/app/components/send/send-content/send-gas-row/send-gas-row.selectors.js
+++ b/ui/app/components/send/send-content/send-gas-row/send-gas-row.selectors.js
@@ -1,6 +1,7 @@
const selectors = {
gasFeeIsInError,
getGasLoadingError,
+ getGasButtonGroupShown,
}
module.exports = selectors
@@ -12,3 +13,7 @@ function getGasLoadingError (state) {
function gasFeeIsInError (state) {
return Boolean(state.send.errors.gasFee)
}
+
+function getGasButtonGroupShown (state) {
+ return state.send.gasButtonGroupShown
+}
diff --git a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js
index 54a92bd2d..059c6cdd3 100644
--- a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js
+++ b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js
@@ -6,9 +6,11 @@ import SendGasRow from '../send-gas-row.component.js'
import SendRowWrapper from '../../send-row-wrapper/send-row-wrapper.component'
import GasFeeDisplay from '../gas-fee-display/gas-fee-display.component'
+import GasPriceButtonGroup from '../../../../gas-customization/gas-price-button-group'
const propsMethodSpies = {
showCustomizeGasModal: sinon.spy(),
+ resetGasButtons: sinon.spy(),
}
describe('SendGasRow Component', function () {
@@ -21,12 +23,18 @@ describe('SendGasRow Component', function () {
gasFeeError={'mockGasFeeError'}
gasLoadingError={false}
gasTotal={'mockGasTotal'}
+ gasButtonGroupShown={false}
showCustomizeGasModal={propsMethodSpies.showCustomizeGasModal}
+ resetGasButtons={propsMethodSpies.resetGasButtons}
+ gasPriceButtonGroupProps={{
+ someGasPriceButtonGroupProp: 'foo',
+ anotherGasPriceButtonGroupProp: 'bar',
+ }}
/>, { context: { t: str => str + '_t' } })
})
afterEach(() => {
- propsMethodSpies.showCustomizeGasModal.resetHistory()
+ propsMethodSpies.resetGasButtons.resetHistory()
})
describe('render', () => {
@@ -41,7 +49,7 @@ describe('SendGasRow Component', function () {
errorType,
} = wrapper.find(SendRowWrapper).props()
- assert.equal(label, 'gasFee_t:')
+ assert.equal(label, 'transactionFee_t:')
assert.equal(showError, 'mockGasFeeError')
assert.equal(errorType, 'gasFee')
})
@@ -56,14 +64,40 @@ describe('SendGasRow Component', function () {
convertedCurrency,
gasLoadingError,
gasTotal,
- onClick,
+ onReset,
} = wrapper.find(SendRowWrapper).childAt(0).props()
assert.equal(conversionRate, 20)
assert.equal(convertedCurrency, 'mockConvertedCurrency')
assert.equal(gasLoadingError, false)
assert.equal(gasTotal, 'mockGasTotal')
+ assert.equal(propsMethodSpies.resetGasButtons.callCount, 0)
+ onReset()
+ assert.equal(propsMethodSpies.resetGasButtons.callCount, 1)
+ })
+
+ it('should render the GasPriceButtonGroup if gasButtonGroupShown is true', () => {
+ wrapper.setProps({ gasButtonGroupShown: true })
+ const rendered = wrapper.find(SendRowWrapper).childAt(0)
+ assert.equal(rendered.children().length, 2)
+
+ const gasPriceButtonGroup = rendered.childAt(0)
+ assert(gasPriceButtonGroup.is(GasPriceButtonGroup))
+ assert(gasPriceButtonGroup.hasClass('gas-price-button-group--small'))
+ assert.equal(gasPriceButtonGroup.props().showCheck, false)
+ assert.equal(gasPriceButtonGroup.props().someGasPriceButtonGroupProp, 'foo')
+ assert.equal(gasPriceButtonGroup.props().anotherGasPriceButtonGroupProp, 'bar')
+ })
+
+ it('should render an advanced options button if gasButtonGroupShown is true', () => {
+ wrapper.setProps({ gasButtonGroupShown: true })
+ const rendered = wrapper.find(SendRowWrapper).childAt(0)
+ assert.equal(rendered.children().length, 2)
+
+ const advancedOptionsButton = rendered.childAt(1)
+ assert.equal(advancedOptionsButton.text(), 'advancedOptions_t')
+
assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 0)
- onClick()
+ advancedOptionsButton.props().onClick()
assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 1)
})
})
diff --git a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-container.test.js b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-container.test.js
index 2ce062505..f0c82e4f7 100644
--- a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-container.test.js
+++ b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-container.test.js
@@ -4,16 +4,27 @@ import sinon from 'sinon'
let mapStateToProps
let mapDispatchToProps
+let mergeProps
const actionSpies = {
showModal: sinon.spy(),
+ setGasPrice: sinon.spy(),
+}
+
+const sendDuckSpies = {
+ showGasButtonGroup: sinon.spy(),
+}
+
+const gasDuckSpies = {
+ resetCustomData: sinon.spy(),
}
proxyquire('../send-gas-row.container.js', {
'react-redux': {
- connect: (ms, md) => {
+ connect: (ms, md, mp) => {
mapStateToProps = ms
mapDispatchToProps = md
+ mergeProps = mp
return () => ({})
},
},
@@ -21,12 +32,21 @@ proxyquire('../send-gas-row.container.js', {
getConversionRate: (s) => `mockConversionRate:${s}`,
getCurrentCurrency: (s) => `mockConvertedCurrency:${s}`,
getGasTotal: (s) => `mockGasTotal:${s}`,
+ getGasPrice: (s) => `mockGasPrice:${s}`,
},
'./send-gas-row.selectors.js': {
getGasLoadingError: (s) => `mockGasLoadingError:${s}`,
gasFeeIsInError: (s) => `mockGasFeeError:${s}`,
+ getGasButtonGroupShown: (s) => `mockGetGasButtonGroupShown:${s}`,
},
'../../../../actions': actionSpies,
+ '../../../../selectors/custom-gas': {
+ getBasicGasEstimateLoadingStatus: (s) => `mockBasicGasEstimateLoadingStatus:${s}`,
+ getRenderableEstimateDataForSmallButtonsFromGWEI: (s) => `mockGasButtonInfo:${s}`,
+ getDefaultActiveButtonIndex: (gasButtonInfo, gasPrice) => gasButtonInfo.length + gasPrice.length,
+ },
+ '../../../../ducks/send.duck': sendDuckSpies,
+ '../../../../ducks/gas.duck': gasDuckSpies,
})
describe('send-gas-row container', () => {
@@ -40,6 +60,13 @@ describe('send-gas-row container', () => {
gasTotal: 'mockGasTotal:mockState',
gasFeeError: 'mockGasFeeError:mockState',
gasLoadingError: 'mockGasLoadingError:mockState',
+ gasPriceButtonGroupProps: {
+ buttonDataLoading: `mockBasicGasEstimateLoadingStatus:mockState`,
+ defaultActiveButtonIndex: 1,
+ newActiveButtonIndex: 49,
+ gasButtonInfo: `mockGasButtonInfo:mockState`,
+ },
+ gasButtonGroupShown: `mockGetGasButtonGroupShown:mockState`,
})
})
@@ -60,11 +87,74 @@ describe('send-gas-row container', () => {
assert(dispatchSpy.calledOnce)
assert.deepEqual(
actionSpies.showModal.getCall(0).args[0],
- { name: 'CUSTOMIZE_GAS' }
+ { name: 'CUSTOMIZE_GAS', hideBasic: true }
)
})
})
+ describe('setGasPrice()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.setGasPrice('mockNewPrice')
+ assert(dispatchSpy.calledOnce)
+ assert(actionSpies.setGasPrice.calledOnce)
+ assert.equal(actionSpies.setGasPrice.getCall(0).args[0], 'mockNewPrice')
+ })
+ })
+
+ describe('showGasButtonGroup()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.showGasButtonGroup()
+ assert(dispatchSpy.calledOnce)
+ assert(sendDuckSpies.showGasButtonGroup.calledOnce)
+ })
+ })
+
+ describe('resetCustomData()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.resetCustomData()
+ assert(dispatchSpy.calledOnce)
+ assert(gasDuckSpies.resetCustomData.calledOnce)
+ })
+ })
+
+ })
+
+ describe('mergeProps', () => {
+ let stateProps
+ let dispatchProps
+ let ownProps
+
+ beforeEach(() => {
+ stateProps = {
+ gasPriceButtonGroupProps: {
+ someGasPriceButtonGroupProp: 'foo',
+ anotherGasPriceButtonGroupProp: 'bar',
+ },
+ someOtherStateProp: 'baz',
+ }
+ dispatchProps = {
+ setGasPrice: sinon.spy(),
+ someOtherDispatchProp: sinon.spy(),
+ }
+ ownProps = { someOwnProp: 123 }
+ })
+
+ it('should return the expected props when isConfirm is true', () => {
+ const result = mergeProps(stateProps, dispatchProps, ownProps)
+
+ assert.equal(result.someOtherStateProp, 'baz')
+ assert.equal(result.gasPriceButtonGroupProps.someGasPriceButtonGroupProp, 'foo')
+ assert.equal(result.gasPriceButtonGroupProps.anotherGasPriceButtonGroupProp, 'bar')
+ assert.equal(result.someOwnProp, 123)
+
+ assert.equal(dispatchProps.setGasPrice.callCount, 0)
+ result.gasPriceButtonGroupProps.handleGasPriceSelection()
+ assert.equal(dispatchProps.setGasPrice.callCount, 1)
+
+ assert.equal(dispatchProps.someOtherDispatchProp.callCount, 0)
+ result.someOtherDispatchProp()
+ assert.equal(dispatchProps.someOtherDispatchProp.callCount, 1)
+ })
})
})
diff --git a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js
index d46dd9d8b..bd3c9a257 100644
--- a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js
+++ b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js
@@ -2,6 +2,7 @@ import assert from 'assert'
import {
gasFeeIsInError,
getGasLoadingError,
+ getGasButtonGroupShown,
} from '../send-gas-row.selectors.js'
describe('send-gas-row selectors', () => {
@@ -46,4 +47,16 @@ describe('send-gas-row selectors', () => {
})
})
+ describe('getGasButtonGroupShown()', () => {
+ it('should return send.gasButtonGroupShown', () => {
+ const state = {
+ send: {
+ gasButtonGroupShown: 'foobar',
+ },
+ }
+
+ assert.equal(getGasButtonGroupShown(state), 'foobar')
+ })
+ })
+
})
diff --git a/ui/app/components/send/send.component.js b/ui/app/components/send/send.component.js
index a27401f30..9b512aaf6 100644
--- a/ui/app/components/send/send.component.js
+++ b/ui/app/components/send/send.component.js
@@ -35,6 +35,7 @@ export default class SendTransactionScreen extends PersistentForm {
selectedToken: PropTypes.object,
tokenBalance: PropTypes.string,
tokenContract: PropTypes.object,
+ fetchBasicGasEstimates: PropTypes.func,
updateAndSetGasTotal: PropTypes.func,
updateSendErrors: PropTypes.func,
updateSendTokenBalance: PropTypes.func,
@@ -73,10 +74,10 @@ export default class SendTransactionScreen extends PersistentForm {
selectedAddress,
selectedToken = {},
to: currentToAddress,
- updateAndSetGasTotal,
+ updateAndSetGasLimit,
} = this.props
- updateAndSetGasTotal({
+ updateAndSetGasLimit({
blockGasLimit,
editingTransactionId,
gasLimit,
@@ -162,6 +163,13 @@ export default class SendTransactionScreen extends PersistentForm {
}
}
+ componentDidMount () {
+ this.props.fetchBasicGasEstimates()
+ .then(() => {
+ this.updateGas()
+ })
+ }
+
componentWillMount () {
const {
from: { address },
@@ -169,12 +177,12 @@ export default class SendTransactionScreen extends PersistentForm {
tokenContract,
updateSendTokenBalance,
} = this.props
+
updateSendTokenBalance({
selectedToken,
tokenContract,
address,
})
- this.updateGas()
// Show QR Scanner modal if ?scan=true
if (window.location.search === '?scan=true') {
diff --git a/ui/app/components/send/send.container.js b/ui/app/components/send/send.container.js
index 87056499f..402e4bbe5 100644
--- a/ui/app/components/send/send.container.js
+++ b/ui/app/components/send/send.container.js
@@ -37,6 +37,9 @@ import {
updateSendErrors,
} from '../../ducks/send.duck'
import {
+ fetchBasicGasEstimates,
+} from '../../ducks/gas.duck'
+import {
calcGasTotal,
} from './send.utils.js'
@@ -76,7 +79,7 @@ function mapStateToProps (state) {
function mapDispatchToProps (dispatch) {
return {
- updateAndSetGasTotal: ({
+ updateAndSetGasLimit: ({
blockGasLimit,
editingTransactionId,
gasLimit,
@@ -89,7 +92,7 @@ function mapDispatchToProps (dispatch) {
data,
}) => {
!editingTransactionId
- ? dispatch(updateGasData({ recentBlocks, selectedAddress, selectedToken, blockGasLimit, to, value, data }))
+ ? dispatch(updateGasData({ gasPrice, recentBlocks, selectedAddress, selectedToken, blockGasLimit, to, value, data }))
: dispatch(setGasTotal(calcGasTotal(gasLimit, gasPrice)))
},
updateSendTokenBalance: ({ selectedToken, tokenContract, address }) => {
@@ -104,5 +107,6 @@ function mapDispatchToProps (dispatch) {
scanQrCode: () => dispatch(showQrScanner(SEND_ROUTE)),
qrCodeDetected: (data) => dispatch(qrCodeDetected(data)),
updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)),
+ fetchBasicGasEstimates: () => dispatch(fetchBasicGasEstimates()),
}
}
diff --git a/ui/app/components/send/send.selectors.js b/ui/app/components/send/send.selectors.js
index c217d848e..443c82af5 100644
--- a/ui/app/components/send/send.selectors.js
+++ b/ui/app/components/send/send.selectors.js
@@ -8,7 +8,11 @@ const {
} = require('../../selectors')
const {
estimateGasPriceFromRecentBlocks,
+ calcGasTotal,
} = require('./send.utils')
+import {
+ getFastPriceEstimateInHexWEI,
+} from '../../selectors/custom-gas'
const selectors = {
accountsWithSendEtherInfoSelector,
@@ -131,11 +135,11 @@ function getForceGasMin (state) {
}
function getGasLimit (state) {
- return state.metamask.send.gasLimit
+ return state.metamask.send.gasLimit || '0'
}
function getGasPrice (state) {
- return state.metamask.send.gasPrice
+ return state.metamask.send.gasPrice || getFastPriceEstimateInHexWEI(state)
}
function getGasPriceFromRecentBlocks (state) {
@@ -143,7 +147,7 @@ function getGasPriceFromRecentBlocks (state) {
}
function getGasTotal (state) {
- return state.metamask.send.gasTotal
+ return calcGasTotal(getGasLimit(state), getGasPrice(state))
}
function getPrimaryCurrency (state) {
diff --git a/ui/app/components/send/tests/send-component.test.js b/ui/app/components/send/tests/send-component.test.js
index bd136a0b8..81955cc1d 100644
--- a/ui/app/components/send/tests/send-component.test.js
+++ b/ui/app/components/send/tests/send-component.test.js
@@ -3,16 +3,23 @@ import assert from 'assert'
import proxyquire from 'proxyquire'
import { shallow } from 'enzyme'
import sinon from 'sinon'
+import timeout from '../../../../lib/test-timeout'
import SendHeader from '../send-header/send-header.container'
import SendContent from '../send-content/send-content.component'
import SendFooter from '../send-footer/send-footer.container'
+const mockBasicGasEstimates = {
+ blockTime: 'mockBlockTime',
+}
+
const propsMethodSpies = {
- updateAndSetGasTotal: sinon.spy(),
+ updateAndSetGasLimit: sinon.spy(),
updateSendErrors: sinon.spy(),
updateSendTokenBalance: sinon.spy(),
resetSendState: sinon.spy(),
+ fetchBasicGasEstimates: sinon.stub().returns(Promise.resolve(mockBasicGasEstimates)),
+ fetchGasEstimates: sinon.spy(),
}
const utilsMethodStubs = {
getAmountErrorObject: sinon.stub().returns({ amount: 'mockAmountError' }),
@@ -37,6 +44,8 @@ describe('Send Component', function () {
blockGasLimit={'mockBlockGasLimit'}
conversionRate={10}
editingTransactionId={'mockEditingTransactionId'}
+ fetchBasicGasEstimates={propsMethodSpies.fetchBasicGasEstimates}
+ fetchGasEstimates={propsMethodSpies.fetchGasEstimates}
from={ { address: 'mockAddress', balance: 'mockBalance' } }
gasLimit={'mockGasLimit'}
gasPrice={'mockGasPrice'}
@@ -50,7 +59,7 @@ describe('Send Component', function () {
showHexData={true}
tokenBalance={'mockTokenBalance'}
tokenContract={'mockTokenContract'}
- updateAndSetGasTotal={propsMethodSpies.updateAndSetGasTotal}
+ updateAndSetGasLimit={propsMethodSpies.updateAndSetGasLimit}
updateSendErrors={propsMethodSpies.updateSendErrors}
updateSendTokenBalance={propsMethodSpies.updateSendTokenBalance}
resetSendState={propsMethodSpies.resetSendState}
@@ -63,7 +72,8 @@ describe('Send Component', function () {
utilsMethodStubs.doesAmountErrorRequireUpdate.resetHistory()
utilsMethodStubs.getAmountErrorObject.resetHistory()
utilsMethodStubs.getGasFeeErrorObject.resetHistory()
- propsMethodSpies.updateAndSetGasTotal.resetHistory()
+ propsMethodSpies.fetchBasicGasEstimates.resetHistory()
+ propsMethodSpies.updateAndSetGasLimit.resetHistory()
propsMethodSpies.updateSendErrors.resetHistory()
propsMethodSpies.updateSendTokenBalance.resetHistory()
})
@@ -72,12 +82,20 @@ describe('Send Component', function () {
assert(SendTransactionScreen.prototype.componentDidMount.calledOnce)
})
- describe('componentWillMount', () => {
- it('should call this.updateGas', () => {
+ describe('componentDidMount', () => {
+ it('should call props.fetchBasicGasAndTimeEstimates', () => {
+ propsMethodSpies.fetchBasicGasEstimates.resetHistory()
+ assert.equal(propsMethodSpies.fetchBasicGasEstimates.callCount, 0)
+ wrapper.instance().componentDidMount()
+ assert.equal(propsMethodSpies.fetchBasicGasEstimates.callCount, 1)
+ })
+
+ it('should call this.updateGas', async () => {
SendTransactionScreen.prototype.updateGas.resetHistory()
propsMethodSpies.updateSendErrors.resetHistory()
assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 0)
- wrapper.instance().componentWillMount()
+ wrapper.instance().componentDidMount()
+ await timeout(250)
assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 1)
})
})
@@ -271,12 +289,12 @@ describe('Send Component', function () {
})
describe('updateGas', () => {
- it('should call updateAndSetGasTotal with the correct params if no to prop is passed', () => {
- propsMethodSpies.updateAndSetGasTotal.resetHistory()
+ it('should call updateAndSetGasLimit with the correct params if no to prop is passed', () => {
+ propsMethodSpies.updateAndSetGasLimit.resetHistory()
wrapper.instance().updateGas()
- assert.equal(propsMethodSpies.updateAndSetGasTotal.callCount, 1)
+ assert.equal(propsMethodSpies.updateAndSetGasLimit.callCount, 1)
assert.deepEqual(
- propsMethodSpies.updateAndSetGasTotal.getCall(0).args[0],
+ propsMethodSpies.updateAndSetGasLimit.getCall(0).args[0],
{
blockGasLimit: 'mockBlockGasLimit',
editingTransactionId: 'mockEditingTransactionId',
@@ -292,20 +310,20 @@ describe('Send Component', function () {
)
})
- it('should call updateAndSetGasTotal with the correct params if a to prop is passed', () => {
- propsMethodSpies.updateAndSetGasTotal.resetHistory()
+ it('should call updateAndSetGasLimit with the correct params if a to prop is passed', () => {
+ propsMethodSpies.updateAndSetGasLimit.resetHistory()
wrapper.setProps({ to: 'someAddress' })
wrapper.instance().updateGas()
assert.equal(
- propsMethodSpies.updateAndSetGasTotal.getCall(0).args[0].to,
+ propsMethodSpies.updateAndSetGasLimit.getCall(0).args[0].to,
'someaddress',
)
})
- it('should call updateAndSetGasTotal with to set to lowercase if passed', () => {
- propsMethodSpies.updateAndSetGasTotal.resetHistory()
+ it('should call updateAndSetGasLimit with to set to lowercase if passed', () => {
+ propsMethodSpies.updateAndSetGasLimit.resetHistory()
wrapper.instance().updateGas({ to: '0xABC' })
- assert.equal(propsMethodSpies.updateAndSetGasTotal.getCall(0).args[0].to, '0xabc')
+ assert.equal(propsMethodSpies.updateAndSetGasLimit.getCall(0).args[0].to, '0xabc')
})
})
diff --git a/ui/app/components/send/tests/send-container.test.js b/ui/app/components/send/tests/send-container.test.js
index 6aa4bf826..19b6563e6 100644
--- a/ui/app/components/send/tests/send-container.test.js
+++ b/ui/app/components/send/tests/send-container.test.js
@@ -94,7 +94,7 @@ describe('send container', () => {
mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
})
- describe('updateAndSetGasTotal()', () => {
+ describe('updateAndSetGasLimit()', () => {
const mockProps = {
blockGasLimit: 'mockBlockGasLimit',
editingTransactionId: '0x2',
@@ -109,7 +109,7 @@ describe('send container', () => {
}
it('should dispatch a setGasTotal action when editingTransactionId is truthy', () => {
- mapDispatchToPropsObject.updateAndSetGasTotal(mockProps)
+ mapDispatchToPropsObject.updateAndSetGasLimit(mockProps)
assert(dispatchSpy.calledOnce)
assert.equal(
actionSpies.setGasTotal.getCall(0).args[0],
@@ -118,14 +118,14 @@ describe('send container', () => {
})
it('should dispatch an updateGasData action when editingTransactionId is falsy', () => {
- const { selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value, data } = mockProps
- mapDispatchToPropsObject.updateAndSetGasTotal(
+ const { gasPrice, selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value, data } = mockProps
+ mapDispatchToPropsObject.updateAndSetGasLimit(
Object.assign({}, mockProps, {editingTransactionId: false})
)
assert(dispatchSpy.calledOnce)
assert.deepEqual(
actionSpies.updateGasData.getCall(0).args[0],
- { selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value, data }
+ { gasPrice, selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value, data }
)
})
})
diff --git a/ui/app/components/send/tests/send-selectors.test.js b/ui/app/components/send/tests/send-selectors.test.js
index e7e901f0d..cdc86fe59 100644
--- a/ui/app/components/send/tests/send-selectors.test.js
+++ b/ui/app/components/send/tests/send-selectors.test.js
@@ -237,7 +237,7 @@ describe('send selectors', () => {
it('should return the send.gasTotal', () => {
assert.equal(
getGasTotal(mockState),
- '0xb451dc41b578'
+ 'a9ff56'
)
})
})
diff --git a/ui/app/components/sender-to-recipient/index.scss b/ui/app/components/sender-to-recipient/index.scss
index 0ab0413be..b21e4e1bb 100644
--- a/ui/app/components/sender-to-recipient/index.scss
+++ b/ui/app/components/sender-to-recipient/index.scss
@@ -1,12 +1,13 @@
.sender-to-recipient {
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ position: relative;
+ flex: 0 0 auto;
+
&--default {
- width: 100%;
- display: flex;
- flex-direction: row;
- justify-content: center;
border-bottom: 1px solid $geyser;
- position: relative;
- flex: 0 0 auto;
height: 42px;
.sender-to-recipient {
@@ -74,13 +75,6 @@
}
&--cards {
- width: 100%;
- display: flex;
- flex-direction: row;
- justify-content: center;
- position: relative;
- flex: 0 0 auto;
-
.sender-to-recipient {
&__party {
display: flex;
@@ -117,4 +111,39 @@
}
}
}
+
+ &--flat {
+ .sender-to-recipient {
+ &__party {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ padding: 6px;
+ cursor: pointer;
+ min-width: 0;
+ color: $dusty-gray;
+ }
+
+ &__tooltip-wrapper {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: .6875rem;
+ }
+
+ &__arrow-container {
+ 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 e71bd7406..89a1a9c08 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
@@ -4,12 +4,13 @@ import classnames from 'classnames'
import Identicon from '../identicon'
import Tooltip from '../tooltip-v2'
import copyToClipboard from 'copy-to-clipboard'
-import { DEFAULT_VARIANT, CARDS_VARIANT } from './sender-to-recipient.constants'
+import { DEFAULT_VARIANT, CARDS_VARIANT, FLAT_VARIANT } from './sender-to-recipient.constants'
import { checksumAddress } from '../../util'
const variantHash = {
[DEFAULT_VARIANT]: 'sender-to-recipient--default',
[CARDS_VARIANT]: 'sender-to-recipient--cards',
+ [FLAT_VARIANT]: 'sender-to-recipient--flat',
}
export default class SenderToRecipient extends PureComponent {
@@ -19,7 +20,7 @@ export default class SenderToRecipient extends PureComponent {
recipientName: PropTypes.string,
recipientAddress: PropTypes.string,
t: PropTypes.func,
- variant: PropTypes.oneOf([DEFAULT_VARIANT, CARDS_VARIANT]),
+ variant: PropTypes.oneOf([DEFAULT_VARIANT, CARDS_VARIANT, FLAT_VARIANT]),
addressOnly: PropTypes.bool,
assetImage: PropTypes.string,
}
@@ -128,16 +129,9 @@ export default class SenderToRecipient extends PureComponent {
}
renderArrow () {
- return this.props.variant === CARDS_VARIANT
+ return this.props.variant === DEFAULT_VARIANT
? (
<div className="sender-to-recipient__arrow-container">
- <img
- height={20}
- src="./images/caret-right.svg"
- />
- </div>
- ) : (
- <div className="sender-to-recipient__arrow-container">
<div className="sender-to-recipient__arrow-circle">
<img
height={15}
@@ -146,6 +140,13 @@ export default class SenderToRecipient extends PureComponent {
/>
</div>
</div>
+ ) : (
+ <div className="sender-to-recipient__arrow-container">
+ <img
+ height={20}
+ src="./images/caret-right.svg"
+ />
+ </div>
)
}
@@ -154,7 +155,7 @@ export default class SenderToRecipient extends PureComponent {
const checksummedSenderAddress = checksumAddress(senderAddress)
return (
- <div className={classnames(variantHash[variant])}>
+ <div className={classnames('sender-to-recipient', variantHash[variant])}>
<div
className={classnames('sender-to-recipient__party sender-to-recipient__party--sender')}
onClick={() => {
diff --git a/ui/app/components/sender-to-recipient/sender-to-recipient.constants.js b/ui/app/components/sender-to-recipient/sender-to-recipient.constants.js
index 166228932..f53a5115d 100644
--- a/ui/app/components/sender-to-recipient/sender-to-recipient.constants.js
+++ b/ui/app/components/sender-to-recipient/sender-to-recipient.constants.js
@@ -1,3 +1,4 @@
// Component design variants
export const DEFAULT_VARIANT = 'DEFAULT_VARIANT'
export const CARDS_VARIANT = 'CARDS_VARIANT'
+export const FLAT_VARIANT = 'FLAT_VARIANT'
diff --git a/ui/app/components/sidebars/index.scss b/ui/app/components/sidebars/index.scss
index 5ab0664df..b9845d564 100644
--- a/ui/app/components/sidebars/index.scss
+++ b/ui/app/components/sidebars/index.scss
@@ -1,3 +1,5 @@
+@import './sidebar-content';
+
.sidebar-right-enter {
transition: transform 300ms ease-in-out;
transform: translateX(-100%);
@@ -58,6 +60,11 @@
width: 408px;
left: calc(100% - 408px);
}
+
+ @media screen and (max-width: $break-small) {
+ width: 100%;
+ left: 0%;
+ }
}
.sidebar-overlay {
diff --git a/ui/app/components/sidebars/sidebar-content.scss b/ui/app/components/sidebars/sidebar-content.scss
new file mode 100644
index 000000000..ca6b0a458
--- /dev/null
+++ b/ui/app/components/sidebars/sidebar-content.scss
@@ -0,0 +1,112 @@
+.sidebar-left {
+ display: flex;
+
+ .gas-modal-page-container {
+ display: flex;
+
+ .page-container {
+ flex: 1;
+ max-width: 100%;
+
+ &__content {
+ display: flex;
+ overflow-y: initial;
+ }
+
+ @media screen and (max-width: $break-small) {
+ max-width: 344px;
+ min-height: auto;
+ }
+
+ @media screen and (min-width: $break-small) {
+ max-height: none;
+ }
+ }
+
+ .gas-price-chart {
+ margin-left: 10px;
+
+ &__root {
+ max-height: 160px !important;
+ }
+ }
+
+ .page-container__bottom {
+ display: flex;
+ flex-direction: column;
+ flex-flow: space-between;
+ height: 100%;
+ }
+
+ .page-container__content {
+ overflow-y: inherit;
+ }
+
+ .basic-tab-content {
+ height: auto;
+ margin-bottom: 0px;
+ border-bottom: 1px solid #d2d8dd;
+ flex: 1 1 70%;
+
+ @media screen and (max-width: $break-small) {
+ padding-left: 14px;
+ padding-bottom: 21px;
+ }
+
+ .gas-price-button-group--alt {
+ @media screen and (max-width: $break-small) {
+ max-width: 318px;
+
+ &__time-estimate {
+ font-size: 12px;
+ }
+ }
+ }
+ }
+
+ .advanced-tab {
+ @media screen and (min-width: $break-small) {
+ flex: 1 1 70%;
+ }
+
+ &__fee-chart {
+ height: 320px;
+
+ @media screen and (max-width: $break-small) {
+ height: initial;
+ }
+ }
+
+ &__fee-chart__speed-buttons {
+ bottom: 77px;
+
+ @media screen and (max-width: $break-small) {
+ display: none;
+ }
+ }
+ }
+
+ .gas-modal-content {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+
+ &__info-row-wrapper {
+ display: flex;
+ @media screen and (min-width: $break-small) {
+ flex: 1 1 30%;
+ }
+ }
+
+ &__info-row {
+ height: 170px;
+
+ @media screen and (max-width: $break-small) {
+ height: initial;
+ display: flex;
+ justify-content: center;
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/ui/app/components/sidebars/sidebar.component.js b/ui/app/components/sidebars/sidebar.component.js
index 57cdd7111..f68515ad6 100644
--- a/ui/app/components/sidebars/sidebar.component.js
+++ b/ui/app/components/sidebars/sidebar.component.js
@@ -3,14 +3,17 @@ import PropTypes from 'prop-types'
import ReactCSSTransitionGroup from 'react-addons-css-transition-group'
import WalletView from '../wallet-view'
import { WALLET_VIEW_SIDEBAR } from './sidebar.constants'
+import CustomizeGas from '../gas-customization/gas-modal-page-container/'
export default class Sidebar extends Component {
static propTypes = {
sidebarOpen: PropTypes.bool,
hideSidebar: PropTypes.func,
+ sidebarShouldClose: PropTypes.bool,
transitionName: PropTypes.string,
type: PropTypes.string,
+ sidebarProps: PropTypes.object,
};
renderOverlay () {
@@ -18,19 +21,27 @@ export default class Sidebar extends Component {
}
renderSidebarContent () {
- const { type } = this.props
-
+ const { type, sidebarProps = {} } = this.props
+ const { transaction = {} } = sidebarProps
switch (type) {
case WALLET_VIEW_SIDEBAR:
return <WalletView responsiveDisplayClassname={'sidebar-right' } />
+ case 'customize-gas':
+ return <div className={'sidebar-left'}><CustomizeGas transaction={transaction} /></div>
default:
return null
}
}
+ componentDidUpdate (prevProps) {
+ if (!prevProps.sidebarShouldClose && this.props.sidebarShouldClose) {
+ this.props.hideSidebar()
+ }
+ }
+
render () {
- const { transitionName, sidebarOpen } = this.props
+ const { transitionName, sidebarOpen, sidebarShouldClose } = this.props
return (
<div>
@@ -39,9 +50,9 @@ export default class Sidebar extends Component {
transitionEnterTimeout={300}
transitionLeaveTimeout={200}
>
- { sidebarOpen ? this.renderSidebarContent() : null }
+ { sidebarOpen && !sidebarShouldClose ? this.renderSidebarContent() : null }
</ReactCSSTransitionGroup>
- { sidebarOpen ? this.renderOverlay() : null }
+ { sidebarOpen && !sidebarShouldClose ? this.renderOverlay() : null }
</div>
)
}
diff --git a/ui/app/components/sidebars/tests/sidebars-component.test.js b/ui/app/components/sidebars/tests/sidebars-component.test.js
index e2d77518a..cee22aca8 100644
--- a/ui/app/components/sidebars/tests/sidebars-component.test.js
+++ b/ui/app/components/sidebars/tests/sidebars-component.test.js
@@ -6,6 +6,7 @@ import ReactCSSTransitionGroup from 'react-addons-css-transition-group'
import Sidebar from '../sidebar.component.js'
import WalletView from '../../wallet-view'
+import CustomizeGas from '../../gas-customization/gas-modal-page-container/'
const propsMethodSpies = {
hideSidebar: sinon.spy(),
@@ -59,6 +60,14 @@ describe('Sidebar Component', function () {
assert.equal(renderSidebarContent.props.responsiveDisplayClassname, 'sidebar-right')
})
+ it('should render sidebar content with the correct props', () => {
+ wrapper.setProps({ type: 'customize-gas' })
+ renderSidebarContent = wrapper.instance().renderSidebarContent()
+ const renderedSidebarContent = shallow(renderSidebarContent)
+ assert(renderedSidebarContent.hasClass('sidebar-left'))
+ assert(renderedSidebarContent.childAt(0).is(CustomizeGas))
+ })
+
it('should not render with an unrecognized type', () => {
wrapper.setProps({ type: 'foobar' })
renderSidebarContent = wrapper.instance().renderSidebarContent()
diff --git a/ui/app/components/signature-request.js b/ui/app/components/signature-request.js
index 85af3b00b..715fea13f 100644
--- a/ui/app/components/signature-request.js
+++ b/ui/app/components/signature-request.js
@@ -164,7 +164,7 @@ SignatureRequest.prototype.msgHexToText = function (hex) {
try {
const stripped = ethUtil.stripHexPrefix(hex)
const buff = Buffer.from(stripped, 'hex')
- return buff.toString('utf8')
+ return buff.length === 32 ? hex : buff.toString('utf8')
} catch (e) {
return hex
}
diff --git a/ui/app/components/transaction-activity-log/index.scss b/ui/app/components/transaction-activity-log/index.scss
index 27f3006b3..00c17e6aa 100644
--- a/ui/app/components/transaction-activity-log/index.scss
+++ b/ui/app/components/transaction-activity-log/index.scss
@@ -1,7 +1,8 @@
.transaction-activity-log {
- &__card {
- background: $white;
- height: 100%;
+ &__title {
+ border-bottom: 1px solid #d8d8d8;
+ padding-bottom: 4px;
+ text-transform: capitalize;
}
&__activities-container {
@@ -21,8 +22,8 @@
left: 0;
top: 0;
height: 100%;
- width: 6px;
- border-right: 1px solid $scorpion;
+ width: 7px;
+ border-right: 1px solid #909090;
}
&:first-child::after {
@@ -40,22 +41,25 @@
}
&__activity-icon {
- width: 13px;
- height: 13px;
+ width: 15px;
+ height: 15px;
margin-right: 6px;
border-radius: 50%;
- background: $scorpion;
+ background: #909090;
flex: 0 0 auto;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1;
}
&__activity-text {
- color: $scorpion;
+ color: $dusty-gray;
font-size: .75rem;
+ cursor: pointer;
- @media screen and (min-width: $break-large) {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ &:hover {
+ color: $black;
}
}
@@ -64,6 +68,16 @@
font-weight: 500;
}
+ &__entry-container {
+ min-width: 0;
+ }
+
+ &__action-link {
+ font-size: .75rem;
+ cursor: pointer;
+ color: $curious-blue;
+ }
+
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
index 8687dbbc7..a2946e53d 100644
--- 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
@@ -2,34 +2,100 @@ 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',
+ const activities = [
+ {
+ eventKey: 'transactionCreated',
+ hash: '0xe46c7f9b39af2fbf1c53e66f72f80343ab54c2c6dba902d51fb98ada08fe1a63',
+ id: 2005383477493174,
+ timestamp: 1543957986150,
value: '0x2386f26fc10000',
+ }, {
+ eventKey: 'transactionSubmitted',
+ hash: '0xe46c7f9b39af2fbf1c53e66f72f80343ab54c2c6dba902d51fb98ada08fe1a63',
+ id: 2005383477493174,
+ timestamp: 1543957987853,
+ value: '0x1319718a5000',
+ }, {
+ eventKey: 'transactionResubmitted',
+ hash: '0x7d09d337fc6f5d6fe2dbf3a6988d69532deb0a82b665f9180b5a20db377eea87',
+ id: 2005383477493175,
+ timestamp: 1543957991563,
+ value: '0x1502634b5800',
+ }, {
+ eventKey: 'transactionConfirmed',
+ hash: '0x7d09d337fc6f5d6fe2dbf3a6988d69532deb0a82b665f9180b5a20db377eea87',
+ id: 2005383477493175,
+ timestamp: 1543958029960,
+ value: '0x1502634b5800',
},
- }
+ ]
const wrapper = shallow(
<TransactionActivityLog
- transaction={transaction}
+ activities={activities}
className="test-class"
+ inlineRetryIndex={-1}
+ inlineCancelIndex={-1}
+ nativeCurrency="ETH"
+ onCancel={() => {}}
+ onRetry={() => {}}
+ primaryTransactionStatus="confirmed"
/>,
{ 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)
+ })
+
+ it('should render inline retry and cancel buttons', () => {
+ const activities = [
+ {
+ eventKey: 'transactionCreated',
+ hash: '0xa',
+ id: 1,
+ timestamp: 1,
+ value: '0x1',
+ }, {
+ eventKey: 'transactionSubmitted',
+ hash: '0xa',
+ id: 1,
+ timestamp: 2,
+ value: '0x1',
+ }, {
+ eventKey: 'transactionResubmitted',
+ hash: '0x7d09d337fc6f5d6fe2dbf3a6988d69532deb0a82b665f9180b5a20db377eea87',
+ id: 2,
+ timestamp: 3,
+ value: '0x1',
+ }, {
+ eventKey: 'transactionCancelAttempted',
+ hash: '0x7d09d337fc6f5d6fe2dbf3a6988d69532deb0a82b665f9180b5a20db377eea87',
+ id: 3,
+ timestamp: 4,
+ value: '0x1',
+ },
+ ]
+
+ const wrapper = shallow(
+ <TransactionActivityLog
+ activities={activities}
+ className="test-class"
+ inlineRetryIndex={2}
+ inlineCancelIndex={3}
+ nativeCurrency="ETH"
+ onCancel={() => {}}
+ onRetry={() => {}}
+ primaryTransactionStatus="pending"
+ />,
+ { 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('.transaction-activity-log__action-link').length, 2)
})
})
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
index 586500408..d014b8886 100644
--- 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
@@ -1,5 +1,130 @@
import assert from 'assert'
-import { getActivities } from '../transaction-activity-log.util'
+import { combineTransactionHistories, getActivities } from '../transaction-activity-log.util'
+
+describe('combineTransactionHistories', () => {
+ it('should return no activites for an empty list of transactions', () => {
+ assert.deepEqual(combineTransactionHistories([]), [])
+ })
+
+ it('should return activities for an array of transactions', () => {
+ const transactions = [
+ {
+ estimatedGas: '0x5208',
+ hash: '0xa14f13d36b3901e352ce3a7acb9b47b001e5a3370f06232a0953c6fc6fad91b3',
+ history: [
+ {
+ 'id': 6400627574331058,
+ 'time': 1543958845581,
+ 'status': 'unapproved',
+ 'metamaskNetworkId': '3',
+ 'loadingDefaults': true,
+ 'txParams': {
+ 'from': '0x50a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706',
+ 'to': '0xc5ae6383e126f901dcb06131d97a88745bfa88d6',
+ 'value': '0x2386f26fc10000',
+ 'gas': '0x5208',
+ 'gasPrice': '0x3b9aca00',
+ },
+ 'type': 'standard',
+ },
+ [{ 'op': 'replace', 'path': '/status', 'value': 'approved', 'note': 'txStateManager: setting status to approved', 'timestamp': 1543958847813 }],
+ [{ 'op': 'replace', 'path': '/status', 'value': 'submitted', 'note': 'txStateManager: setting status to submitted', 'timestamp': 1543958848147 }],
+ [{ 'op': 'replace', 'path': '/status', 'value': 'dropped', 'note': 'txStateManager: setting status to dropped', 'timestamp': 1543958897181 }, { 'op': 'add', 'path': '/replacedBy', 'value': '0xecbe181ee67c4291d04a7cb9ffbf1d5d831e4fbaa89994fd06bab5dd4cc79b33' }],
+ ],
+ id: 6400627574331058,
+ loadingDefaults: false,
+ metamaskNetworkId: '3',
+ status: 'dropped',
+ submittedTime: 1543958848135,
+ time: 1543958845581,
+ txParams: {
+ from: '0x50a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706',
+ gas: '0x5208',
+ gasPrice: '0x3b9aca00',
+ nonce: '0x32',
+ to: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6',
+ value: '0x2386f26fc10000',
+ },
+ type: 'standard',
+ }, {
+ hash: '0xecbe181ee67c4291d04a7cb9ffbf1d5d831e4fbaa89994fd06bab5dd4cc79b33',
+ history: [
+ {
+ 'id': 6400627574331060,
+ 'time': 1543958857697,
+ 'status': 'unapproved',
+ 'metamaskNetworkId': '3',
+ 'loadingDefaults': false,
+ 'txParams': {
+ 'from': '0x50a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706',
+ 'to': '0xc5ae6383e126f901dcb06131d97a88745bfa88d6',
+ 'value': '0x2386f26fc10000',
+ 'gas': '0x5208',
+ 'gasPrice': '0x3b9aca00',
+ 'nonce': '0x32',
+ },
+ 'lastGasPrice': '0x4190ab00',
+ 'type': 'retry',
+ },
+ [{ 'op': 'replace', 'path': '/txParams/gasPrice', 'value': '0x481f2280', 'note': 'confTx: user approved transaction', 'timestamp': 1543958859470 }],
+ [{ 'op': 'replace', 'path': '/status', 'value': 'approved', 'note': 'txStateManager: setting status to approved', 'timestamp': 1543958859485 }],
+ [{ 'op': 'replace', 'path': '/status', 'value': 'signed', 'note': 'transactions#publishTransaction', 'timestamp': 1543958859889 }],
+ [{ 'op': 'replace', 'path': '/status', 'value': 'submitted', 'note': 'txStateManager: setting status to submitted', 'timestamp': 1543958860061 }], [{ 'op': 'add', 'path': '/firstRetryBlockNumber', 'value': '0x45a0fd', 'note': 'transactions/pending-tx-tracker#event: tx:block-update', 'timestamp': 1543958896466 }],
+ [{ 'op': 'replace', 'path': '/status', 'value': 'confirmed', 'timestamp': 1543958897165 }],
+ ],
+ id: 6400627574331060,
+ lastGasPrice: '0x4190ab00',
+ loadingDefaults: false,
+ metamaskNetworkId: '3',
+ status: 'confirmed',
+ submittedTime: 1543958860054,
+ time: 1543958857697,
+ txParams: {
+ from: '0x50a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706',
+ gas: '0x5208',
+ gasPrice: '0x481f2280',
+ nonce: '0x32',
+ to: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6',
+ value: '0x2386f26fc10000',
+ },
+ txReceipt: {
+ status: '0x1',
+ },
+ type: 'retry',
+ },
+ ]
+
+ const expected = [
+ {
+ id: 6400627574331058,
+ hash: '0xa14f13d36b3901e352ce3a7acb9b47b001e5a3370f06232a0953c6fc6fad91b3',
+ eventKey: 'transactionCreated',
+ timestamp: 1543958845581,
+ value: '0x2386f26fc10000',
+ }, {
+ id: 6400627574331058,
+ hash: '0xa14f13d36b3901e352ce3a7acb9b47b001e5a3370f06232a0953c6fc6fad91b3',
+ eventKey: 'transactionSubmitted',
+ timestamp: 1543958848147,
+ value: '0x1319718a5000',
+ }, {
+ id: 6400627574331060,
+ hash: '0xecbe181ee67c4291d04a7cb9ffbf1d5d831e4fbaa89994fd06bab5dd4cc79b33',
+ eventKey: 'transactionResubmitted',
+ timestamp: 1543958860061,
+ value: '0x171c3a061400',
+ }, {
+ id: 6400627574331060,
+ hash: '0xecbe181ee67c4291d04a7cb9ffbf1d5d831e4fbaa89994fd06bab5dd4cc79b33',
+ eventKey: 'transactionConfirmed',
+ timestamp: 1543958897165,
+ value: '0x171c3a061400',
+ },
+ ]
+
+ assert.deepEqual(combineTransactionHistories(transactions), expected)
+ })
+})
describe('getActivities', () => {
it('should return no activities for an empty history', () => {
@@ -178,6 +303,7 @@ describe('getActivities', () => {
to: '0x2',
value: '0x2386f26fc10000',
},
+ hash: '0xabc',
}
const expectedResult = [
@@ -185,24 +311,25 @@ describe('getActivities', () => {
'eventKey': 'transactionCreated',
'timestamp': 1535507561452,
'value': '0x2386f26fc10000',
- },
- {
- 'eventKey': 'transactionUpdatedGas',
- 'timestamp': 1535664571504,
- 'value': '0x77359400',
+ 'id': 1,
+ 'hash': '0xabc',
},
{
'eventKey': 'transactionSubmitted',
'timestamp': 1535507564665,
- 'value': undefined,
+ 'value': '0x2632e314a000',
+ 'id': 1,
+ 'hash': '0xabc',
},
{
'eventKey': 'transactionConfirmed',
'timestamp': 1535507615993,
- 'value': undefined,
+ 'value': '0x2632e314a000',
+ 'id': 1,
+ 'hash': '0xabc',
},
]
- assert.deepEqual(getActivities(transaction), expectedResult)
+ assert.deepEqual(getActivities(transaction, true), expectedResult)
})
})
diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log-icon/index.js b/ui/app/components/transaction-activity-log/transaction-activity-log-icon/index.js
new file mode 100644
index 000000000..86b12360a
--- /dev/null
+++ b/ui/app/components/transaction-activity-log/transaction-activity-log-icon/index.js
@@ -0,0 +1 @@
+export { default } from './transaction-activity-log-icon.component'
diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log-icon/transaction-activity-log-icon.component.js b/ui/app/components/transaction-activity-log/transaction-activity-log-icon/transaction-activity-log-icon.component.js
new file mode 100644
index 000000000..871716002
--- /dev/null
+++ b/ui/app/components/transaction-activity-log/transaction-activity-log-icon/transaction-activity-log-icon.component.js
@@ -0,0 +1,55 @@
+import React, { PureComponent } from 'react'
+import PropTypes from 'prop-types'
+import classnames from 'classnames'
+
+import {
+ TRANSACTION_CREATED_EVENT,
+ TRANSACTION_SUBMITTED_EVENT,
+ TRANSACTION_RESUBMITTED_EVENT,
+ TRANSACTION_CONFIRMED_EVENT,
+ TRANSACTION_DROPPED_EVENT,
+ TRANSACTION_ERRORED_EVENT,
+ TRANSACTION_CANCEL_ATTEMPTED_EVENT,
+ TRANSACTION_CANCEL_SUCCESS_EVENT,
+} from '../transaction-activity-log.constants'
+
+const imageHash = {
+ [TRANSACTION_CREATED_EVENT]: '/images/icons/new.svg',
+ [TRANSACTION_SUBMITTED_EVENT]: '/images/icons/submitted.svg',
+ [TRANSACTION_RESUBMITTED_EVENT]: '/images/icons/retry.svg',
+ [TRANSACTION_CONFIRMED_EVENT]: '/images/icons/confirm.svg',
+ [TRANSACTION_DROPPED_EVENT]: '/images/icons/cancelled.svg',
+ [TRANSACTION_ERRORED_EVENT]: '/images/icons/error.svg',
+ [TRANSACTION_CANCEL_ATTEMPTED_EVENT]: '/images/icons/cancelled.svg',
+ [TRANSACTION_CANCEL_SUCCESS_EVENT]: '/images/icons/cancelled.svg',
+}
+
+export default class TransactionActivityLogIcon extends PureComponent {
+ static contextTypes = {
+ t: PropTypes.func,
+ }
+
+ static propTypes = {
+ className: PropTypes.string,
+ eventKey: PropTypes.oneOf(Object.keys(imageHash)),
+ }
+
+ render () {
+ const { className, eventKey } = this.props
+ const imagePath = imageHash[eventKey]
+
+ return (
+ <div className={classnames('transaction-activity-log-icon', className)}>
+ {
+ imagePath && (
+ <img
+ src={imagePath}
+ height={9}
+ width={9}
+ />
+ )
+ }
+ </div>
+ )
+ }
+}
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
index 58d932a0f..d6f90860a 100644
--- a/ui/app/components/transaction-activity-log/transaction-activity-log.component.js
+++ b/ui/app/components/transaction-activity-log/transaction-activity-log.component.js
@@ -1,10 +1,11 @@
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 { formatDate } from '../../util'
+import TransactionActivityLogIcon from './transaction-activity-log-icon'
+import { CONFIRMED_STATUS } from './transaction-activity-log.constants'
+import prefixForNetwork from '../../../lib/etherscan-prefix-for-network'
export default class TransactionActivityLog extends PureComponent {
static contextTypes = {
@@ -12,41 +13,64 @@ export default class TransactionActivityLog extends PureComponent {
}
static propTypes = {
- transaction: PropTypes.object,
+ activities: PropTypes.array,
className: PropTypes.string,
conversionRate: PropTypes.number,
+ inlineRetryIndex: PropTypes.number,
+ inlineCancelIndex: PropTypes.number,
nativeCurrency: PropTypes.string,
+ onCancel: PropTypes.func,
+ onRetry: PropTypes.func,
+ primaryTransaction: PropTypes.object,
}
- state = {
- activities: [],
- }
+ handleActivityClick = hash => {
+ const { primaryTransaction } = this.props
+ const { metamaskNetworkId } = primaryTransaction
+
+ const prefix = prefixForNetwork(metamaskNetworkId)
+ const etherscanUrl = `https://${prefix}etherscan.io/tx/${hash}`
- componentDidMount () {
- this.setActivites()
+ global.platform.openWindow({ url: etherscanUrl })
}
- componentDidUpdate (prevProps) {
- const {
- transaction: { history: prevHistory = [], txReceipt: { status: prevStatus } = {} } = {},
- } = prevProps
- const {
- transaction: { history = [], txReceipt: { status } = {} } = {},
- } = this.props
+ renderInlineRetry (index, activity) {
+ const { t } = this.context
+ const { inlineRetryIndex, primaryTransaction = {}, onRetry } = this.props
+ const { status } = primaryTransaction
+ const { id } = activity
- if (prevHistory.length !== history.length || prevStatus !== status) {
- this.setActivites()
- }
+ return status !== CONFIRMED_STATUS && index === inlineRetryIndex
+ ? (
+ <div
+ className="transaction-activity-log__action-link"
+ onClick={() => onRetry(id)}
+ >
+ { t('speedUpTransaction') }
+ </div>
+ ) : null
}
- setActivites () {
- const activities = getActivities(this.props.transaction)
- this.setState({ activities })
+ renderInlineCancel (index, activity) {
+ const { t } = this.context
+ const { inlineCancelIndex, primaryTransaction = {}, onCancel } = this.props
+ const { status } = primaryTransaction
+ const { id } = activity
+
+ return status !== CONFIRMED_STATUS && index === inlineCancelIndex
+ ? (
+ <div
+ className="transaction-activity-log__action-link"
+ onClick={() => onCancel(id)}
+ >
+ { t('speedUpCancellation') }
+ </div>
+ ) : null
}
renderActivity (activity, index) {
const { conversionRate, nativeCurrency } = this.props
- const { eventKey, value, timestamp } = activity
+ const { eventKey, value, timestamp, hash } = activity
const ethValue = index === 0
? `${getValueFromWeiHex({
value,
@@ -55,8 +79,13 @@ export default class TransactionActivityLog extends PureComponent {
conversionRate,
numberOfDecimals: 6,
})} ${nativeCurrency}`
- : getEthConversionFromWeiHex({ value, fromCurrency: nativeCurrency, conversionRate })
- const formattedTimestamp = formatDate(timestamp)
+ : getEthConversionFromWeiHex({
+ value,
+ fromCurrency: nativeCurrency,
+ conversionRate,
+ numberOfDecimals: 3,
+ })
+ const formattedTimestamp = formatDate(timestamp, '14:30 on 3/16/2014')
const activityText = this.context.t(eventKey, [ethValue, formattedTimestamp])
return (
@@ -64,12 +93,20 @@ export default class TransactionActivityLog extends PureComponent {
key={index}
className="transaction-activity-log__activity"
>
- <div className="transaction-activity-log__activity-icon" />
- <div
- className="transaction-activity-log__activity-text"
- title={activityText}
- >
- { activityText }
+ <TransactionActivityLogIcon
+ className="transaction-activity-log__activity-icon"
+ eventKey={eventKey}
+ />
+ <div className="transaction-activity-log__entry-container">
+ <div
+ className="transaction-activity-log__activity-text"
+ title={activityText}
+ onClick={() => this.handleActivityClick(hash)}
+ >
+ { activityText }
+ </div>
+ { this.renderInlineRetry(index, activity) }
+ { this.renderInlineCancel(index, activity) }
</div>
</div>
)
@@ -77,19 +114,16 @@ export default class TransactionActivityLog extends PureComponent {
render () {
const { t } = this.context
- const { className } = this.props
- const { activities } = this.state
+ const { className, activities } = this.props
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 className="transaction-activity-log__title">
+ { t('activityLog') }
+ </div>
+ <div className="transaction-activity-log__activities-container">
+ { activities.map((activity, index) => this.renderActivity(activity, index)) }
+ </div>
</div>
)
}
diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.constants.js b/ui/app/components/transaction-activity-log/transaction-activity-log.constants.js
new file mode 100644
index 000000000..72e63d85c
--- /dev/null
+++ b/ui/app/components/transaction-activity-log/transaction-activity-log.constants.js
@@ -0,0 +1,13 @@
+export const TRANSACTION_CREATED_EVENT = 'transactionCreated'
+export const TRANSACTION_SUBMITTED_EVENT = 'transactionSubmitted'
+export const TRANSACTION_RESUBMITTED_EVENT = 'transactionResubmitted'
+export const TRANSACTION_CONFIRMED_EVENT = 'transactionConfirmed'
+export const TRANSACTION_DROPPED_EVENT = 'transactionDropped'
+export const TRANSACTION_UPDATED_EVENT = 'transactionUpdated'
+export const TRANSACTION_ERRORED_EVENT = 'transactionErrored'
+export const TRANSACTION_CANCEL_ATTEMPTED_EVENT = 'transactionCancelAttempted'
+export const TRANSACTION_CANCEL_SUCCESS_EVENT = 'transactionCancelSuccess'
+
+export const SUBMITTED_STATUS = 'submitted'
+export const CONFIRMED_STATUS = 'confirmed'
+export const DROPPED_STATUS = 'dropped'
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
index 622f77df1..e43229708 100644
--- a/ui/app/components/transaction-activity-log/transaction-activity-log.container.js
+++ b/ui/app/components/transaction-activity-log/transaction-activity-log.container.js
@@ -1,6 +1,14 @@
import { connect } from 'react-redux'
+import R from 'ramda'
import TransactionActivityLog from './transaction-activity-log.component'
import { conversionRateSelector, getNativeCurrency } from '../../selectors'
+import { combineTransactionHistories } from './transaction-activity-log.util'
+import {
+ TRANSACTION_RESUBMITTED_EVENT,
+ TRANSACTION_CANCEL_ATTEMPTED_EVENT,
+} from './transaction-activity-log.constants'
+
+const matchesEventKey = matchEventKey => ({ eventKey }) => eventKey === matchEventKey
const mapStateToProps = state => {
return {
@@ -9,4 +17,28 @@ const mapStateToProps = state => {
}
}
-export default connect(mapStateToProps)(TransactionActivityLog)
+const mergeProps = (stateProps, dispatchProps, ownProps) => {
+ const {
+ transactionGroup: {
+ transactions = [],
+ primaryTransaction,
+ } = {},
+ ...restOwnProps
+ } = ownProps
+
+ const activities = combineTransactionHistories(transactions)
+ const inlineRetryIndex = R.findLastIndex(matchesEventKey(TRANSACTION_RESUBMITTED_EVENT))(activities)
+ const inlineCancelIndex = R.findLastIndex(matchesEventKey(TRANSACTION_CANCEL_ATTEMPTED_EVENT))(activities)
+
+ return {
+ ...stateProps,
+ ...dispatchProps,
+ ...restOwnProps,
+ activities,
+ inlineRetryIndex,
+ inlineCancelIndex,
+ primaryTransaction,
+ }
+}
+
+export default connect(mapStateToProps, null, mergeProps)(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
index 16597ae1a..6206a4678 100644
--- a/ui/app/components/transaction-activity-log/transaction-activity-log.util.js
+++ b/ui/app/components/transaction-activity-log/transaction-activity-log.util.js
@@ -1,28 +1,39 @@
+import { getHexGasTotal } from '../../helpers/confirm-transaction/util'
+
// 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'
+const GAS_LIMIT_PATH = '/txParams/gas'
// 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 TRANSACTION_ERRORED_EVENT = 'transactionErrored'
+import {
+ // event constants
+ TRANSACTION_CREATED_EVENT,
+ TRANSACTION_SUBMITTED_EVENT,
+ TRANSACTION_RESUBMITTED_EVENT,
+ TRANSACTION_CONFIRMED_EVENT,
+ TRANSACTION_DROPPED_EVENT,
+ TRANSACTION_UPDATED_EVENT,
+ TRANSACTION_ERRORED_EVENT,
+ TRANSACTION_CANCEL_ATTEMPTED_EVENT,
+ TRANSACTION_CANCEL_SUCCESS_EVENT,
+ // status constants
+ SUBMITTED_STATUS,
+ CONFIRMED_STATUS,
+ DROPPED_STATUS,
+} from './transaction-activity-log.constants'
+
+import {
+ TRANSACTION_TYPE_CANCEL,
+ TRANSACTION_TYPE_RETRY,
+} from '../../../../app/scripts/controllers/transactions/enums'
const eventPathsHash = {
[STATUS_PATH]: true,
[GAS_PRICE_PATH]: true,
+ [GAS_LIMIT_PATH]: true,
}
const statusHash = {
@@ -31,22 +42,39 @@ const statusHash = {
[DROPPED_STATUS]: TRANSACTION_DROPPED_EVENT,
}
-function eventCreator (eventKey, timestamp, value) {
- return {
- eventKey,
- timestamp,
- value,
- }
-}
-
-export function getActivities (transaction) {
- const { history = [], txReceipt: { status } = {} } = transaction
-
- const historyActivities = history.reduce((acc, base) => {
+/**
+ * @name getActivities
+ * @param {Object} transaction - txMeta object
+ * @param {boolean} isFirstTransaction - True if the transaction is the first created transaction
+ * in the list of transactions with the same nonce. If so, we use this transaction to create the
+ * transactionCreated activity.
+ * @returns {Array}
+ */
+export function getActivities (transaction, isFirstTransaction = false) {
+ const { id, hash, history = [], txReceipt: { status } = {}, type } = transaction
+
+ let cachedGasLimit = '0x0'
+ let cachedGasPrice = '0x0'
+
+ const historyActivities = history.reduce((acc, base, index) => {
// 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))
+ if (index === 0 && !Array.isArray(base) && base.txParams) {
+ const { time: timestamp, txParams: { value, gas = '0x0', gasPrice = '0x0' } = {} } = base
+ // The cached gas limit and gas price are used to display the gas fee in the activity log. We
+ // need to cache these values because the status update history events don't provide us with
+ // the latest gas limit and gas price.
+ cachedGasLimit = gas
+ cachedGasPrice = gasPrice
+
+ if (isFirstTransaction) {
+ return acc.concat({
+ id,
+ hash,
+ eventKey: TRANSACTION_CREATED_EVENT,
+ timestamp,
+ value,
+ })
+ }
// An entry in the history may be an array of more sub-entries.
} else if (Array.isArray(base)) {
const events = []
@@ -60,20 +88,69 @@ export function getActivities (transaction) {
if (path in eventPathsHash && op === REPLACE_OP) {
switch (path) {
case STATUS_PATH: {
+ const gasFee = getHexGasTotal({ gasLimit: cachedGasLimit, gasPrice: cachedGasPrice })
+
if (value in statusHash) {
- events.push(eventCreator(statusHash[value], timestamp))
+ let eventKey = statusHash[value]
+
+ // If the status is 'submitted', we need to determine whether the event is a
+ // transaction retry or a cancellation attempt.
+ if (value === SUBMITTED_STATUS) {
+ if (type === TRANSACTION_TYPE_RETRY) {
+ eventKey = TRANSACTION_RESUBMITTED_EVENT
+ } else if (type === TRANSACTION_TYPE_CANCEL) {
+ eventKey = TRANSACTION_CANCEL_ATTEMPTED_EVENT
+ }
+ } else if (value === CONFIRMED_STATUS) {
+ if (type === TRANSACTION_TYPE_CANCEL) {
+ eventKey = TRANSACTION_CANCEL_SUCCESS_EVENT
+ }
+ }
+
+ events.push({
+ id,
+ hash,
+ eventKey,
+ timestamp,
+ value: gasFee,
+ })
}
break
}
- case GAS_PRICE_PATH: {
- events.push(eventCreator(TRANSACTION_UPDATED_GAS_EVENT, timestamp, value))
+ // If the gas price or gas limit has been changed, we update the gasFee of the
+ // previously submitted event. These events happen when the gas limit and gas price is
+ // changed at the confirm screen.
+ case GAS_PRICE_PATH:
+ case GAS_LIMIT_PATH: {
+ const lastEvent = events[events.length - 1] || {}
+ const { lastEventKey } = lastEvent
+
+ if (path === GAS_LIMIT_PATH) {
+ cachedGasLimit = value
+ } else if (path === GAS_PRICE_PATH) {
+ cachedGasPrice = value
+ }
+
+ if (lastEventKey === TRANSACTION_SUBMITTED_EVENT ||
+ lastEventKey === TRANSACTION_RESUBMITTED_EVENT) {
+ lastEvent.value = getHexGasTotal({
+ gasLimit: cachedGasLimit,
+ gasPrice: cachedGasPrice,
+ })
+ }
+
break
}
default: {
- events.push(eventCreator(TRANSACTION_UPDATED_EVENT, timestamp))
+ events.push({
+ id,
+ hash,
+ eventKey: TRANSACTION_UPDATED_EVENT,
+ timestamp,
+ })
}
}
}
@@ -88,6 +165,60 @@ export function getActivities (transaction) {
// If txReceipt.status is '0x0', that means that an on-chain error occured for the transaction,
// so we add an error entry to the Activity Log.
return status === '0x0'
- ? historyActivities.concat(eventCreator(TRANSACTION_ERRORED_EVENT))
+ ? historyActivities.concat({ id, hash, eventKey: TRANSACTION_ERRORED_EVENT })
: historyActivities
}
+
+/**
+ * @description Removes "Transaction dropped" activities from a list of sorted activities if one of
+ * the transactions has been confirmed. Typically, if multiple transactions have the same nonce,
+ * once one transaction is confirmed, the rest are dropped. In this case, we don't want to show
+ * multiple "Transaction dropped" activities, and instead want to show a single "Transaction
+ * confirmed".
+ * @param {Array} activities - List of sorted activities generated from the getActivities function.
+ * @returns {Array}
+ */
+function filterSortedActivities (activities) {
+ const filteredActivities = []
+ const hasConfirmedActivity = Boolean(activities.find(({ eventKey }) => (
+ eventKey === TRANSACTION_CONFIRMED_EVENT || eventKey === TRANSACTION_CANCEL_SUCCESS_EVENT
+ )))
+ let addedDroppedActivity = false
+
+ activities.forEach(activity => {
+ if (activity.eventKey === TRANSACTION_DROPPED_EVENT) {
+ if (!hasConfirmedActivity && !addedDroppedActivity) {
+ filteredActivities.push(activity)
+ addedDroppedActivity = true
+ }
+ } else {
+ filteredActivities.push(activity)
+ }
+ })
+
+ return filteredActivities
+}
+
+/**
+ * Combines the histories of an array of transactions into a single array.
+ * @param {Array} transactions - Array of txMeta transaction objects.
+ * @returns {Array}
+ */
+export function combineTransactionHistories (transactions = []) {
+ if (!transactions.length) {
+ return []
+ }
+
+ const activities = []
+
+ transactions.forEach((transaction, index) => {
+ // The first transaction should be the transaction with the earliest submittedTime. We show the
+ // 'created' and 'submitted' activities here. All subsequent transactions will use 'resubmitted'
+ // instead.
+ const transactionActivities = getActivities(transaction, index === 0)
+ activities.push(...transactionActivities)
+ })
+
+ const sortedActivities = activities.sort((a, b) => a.timestamp - b.timestamp)
+ return filterSortedActivities(sortedActivities)
+}
diff --git a/ui/app/components/transaction-breakdown/index.scss b/ui/app/components/transaction-breakdown/index.scss
index 1bb108943..b56cbdd7f 100644
--- a/ui/app/components/transaction-breakdown/index.scss
+++ b/ui/app/components/transaction-breakdown/index.scss
@@ -1,9 +1,10 @@
@import './transaction-breakdown-row/index';
.transaction-breakdown {
- &__card {
- background: $white;
- height: 100%;
+ &__title {
+ border-bottom: 1px solid #d8d8d8;
+ padding-bottom: 4px;
+ text-transform: capitalize;
}
&__row-title {
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
index d18cd420c..4512b84f0 100644
--- a/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js
+++ b/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js
@@ -2,8 +2,6 @@ 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', () => {
@@ -31,7 +29,5 @@ describe('TransactionBreakdown Component', () => {
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.component.js b/ui/app/components/transaction-breakdown/transaction-breakdown.component.js
index 3a7647873..141e16e17 100644
--- a/ui/app/components/transaction-breakdown/transaction-breakdown.component.js
+++ b/ui/app/components/transaction-breakdown/transaction-breakdown.component.js
@@ -2,7 +2,6 @@ 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 UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display'
import HexToDecimal from '../hex-to-decimal'
@@ -37,63 +36,61 @@ export default class TransactionBreakdown extends PureComponent {
return (
<div className={classnames('transaction-breakdown', className)}>
- <Card
- title={t('transaction')}
- className="transaction-breakdown__card"
+ <div className="transaction-breakdown__title">
+ { t('transaction') }
+ </div>
+ <TransactionBreakdownRow title={t('amount')}>
+ <UserPreferencedCurrencyDisplay
+ className="transaction-breakdown__value"
+ type={PRIMARY}
+ value={value}
+ />
+ </TransactionBreakdownRow>
+ <TransactionBreakdownRow
+ title={`${t('gasLimit')} (${t('units')})`}
+ className="transaction-breakdown__row-title"
>
- <TransactionBreakdownRow title={t('amount')}>
+ <HexToDecimal
+ className="transaction-breakdown__value"
+ value={gas}
+ />
+ </TransactionBreakdownRow>
+ {
+ typeof gasUsed === 'string' && (
+ <TransactionBreakdownRow
+ title={`${t('gasUsed')} (${t('units')})`}
+ className="transaction-breakdown__row-title"
+ >
+ <HexToDecimal
+ className="transaction-breakdown__value"
+ value={gasUsed}
+ />
+ </TransactionBreakdownRow>
+ )
+ }
+ <TransactionBreakdownRow title={t('gasPrice')}>
+ <CurrencyDisplay
+ className="transaction-breakdown__value"
+ currency={nativeCurrency}
+ denomination={GWEI}
+ value={gasPrice}
+ hideLabel
+ />
+ </TransactionBreakdownRow>
+ <TransactionBreakdownRow title={t('total')}>
+ <div>
<UserPreferencedCurrencyDisplay
- className="transaction-breakdown__value"
+ className="transaction-breakdown__value transaction-breakdown__value--eth-total"
type={PRIMARY}
- value={value}
- />
- </TransactionBreakdownRow>
- <TransactionBreakdownRow
- title={`${t('gasLimit')} (${t('units')})`}
- className="transaction-breakdown__row-title"
- >
- <HexToDecimal
- className="transaction-breakdown__value"
- value={gas}
+ value={totalInHex}
/>
- </TransactionBreakdownRow>
- {
- typeof gasUsed === 'string' && (
- <TransactionBreakdownRow
- title={`${t('gasUsed')} (${t('units')})`}
- className="transaction-breakdown__row-title"
- >
- <HexToDecimal
- className="transaction-breakdown__value"
- value={gasUsed}
- />
- </TransactionBreakdownRow>
- )
- }
- <TransactionBreakdownRow title={t('gasPrice')}>
- <CurrencyDisplay
+ <UserPreferencedCurrencyDisplay
className="transaction-breakdown__value"
- currency={nativeCurrency}
- denomination={GWEI}
- value={gasPrice}
- hideLabel
+ type={SECONDARY}
+ value={totalInHex}
/>
- </TransactionBreakdownRow>
- <TransactionBreakdownRow title={t('total')}>
- <div>
- <UserPreferencedCurrencyDisplay
- className="transaction-breakdown__value transaction-breakdown__value--eth-total"
- type={PRIMARY}
- value={totalInHex}
- />
- <UserPreferencedCurrencyDisplay
- className="transaction-breakdown__value"
- type={SECONDARY}
- value={totalInHex}
- />
- </div>
- </TransactionBreakdownRow>
- </Card>
+ </div>
+ </TransactionBreakdownRow>
</div>
)
}
diff --git a/ui/app/components/transaction-list-item-details/index.scss b/ui/app/components/transaction-list-item-details/index.scss
index 54cf834cc..2e3a06f84 100644
--- a/ui/app/components/transaction-list-item-details/index.scss
+++ b/ui/app/components/transaction-list-item-details/index.scss
@@ -1,11 +1,16 @@
.transaction-list-item-details {
&__header {
- margin-bottom: 8px;
+ margin: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
+ &__body {
+ background: #fafbfc;
+ padding: 8px 16px;
+ }
+
&__header-buttons {
display: flex;
flex-direction: row;
@@ -45,5 +50,9 @@
&__transaction-activity-log {
flex: 2;
min-width: 0;
+
+ @media screen and (min-width: $break-large) {
+ padding-left: 12px;
+ }
}
}
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
index f2bbe8789..62fc64db9 100644
--- 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
@@ -23,9 +23,15 @@ describe('TransactionListItemDetails Component', () => {
},
}
+ const transactionGroup = {
+ transactions: [transaction],
+ primaryTransaction: transaction,
+ initialTransaction: transaction,
+ }
+
const wrapper = shallow(
<TransactionListItemDetails
- transaction={transaction}
+ transactionGroup={transactionGroup}
/>,
{ context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } }
)
@@ -52,9 +58,18 @@ describe('TransactionListItemDetails Component', () => {
},
}
+ const transactionGroup = {
+ transactions: [transaction],
+ primaryTransaction: transaction,
+ initialTransaction: transaction,
+ nonce: '0xa4',
+ hasRetried: false,
+ hasCancelled: false,
+ }
+
const wrapper = shallow(
<TransactionListItemDetails
- transaction={transaction}
+ transactionGroup={transactionGroup}
showRetry={true}
/>,
{ context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } }
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
index a4f28fd63..cc2c45290 100644
--- 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
@@ -1,7 +1,7 @@
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 { FLAT_VARIANT } from '../sender-to-recipient/sender-to-recipient.constants'
import TransactionActivityLog from '../transaction-activity-log'
import TransactionBreakdown from '../transaction-breakdown'
import Button from '../button'
@@ -18,41 +18,43 @@ export default class TransactionListItemDetails extends PureComponent {
onRetry: PropTypes.func,
showCancel: PropTypes.bool,
showRetry: PropTypes.bool,
- transaction: PropTypes.object,
+ transactionGroup: PropTypes.object,
}
handleEtherscanClick = () => {
- const { hash, metamaskNetworkId } = this.props.transaction
+ const { transactionGroup: { primaryTransaction } } = this.props
+ const { hash, metamaskNetworkId } = primaryTransaction
const prefix = prefixForNetwork(metamaskNetworkId)
const etherscanUrl = `https://${prefix}etherscan.io/tx/${hash}`
+
global.platform.openWindow({ url: etherscanUrl })
- this.setState({ showTransactionDetails: true })
}
handleCancel = event => {
- const { onCancel } = this.props
+ const { transactionGroup: { initialTransaction: { id } = {} } = {}, onCancel } = this.props
event.stopPropagation()
- onCancel()
+ onCancel(id)
}
handleRetry = event => {
- const { onRetry } = this.props
+ const { transactionGroup: { initialTransaction: { id } = {} } = {}, onRetry } = this.props
event.stopPropagation()
- onRetry()
+ onRetry(id)
}
render () {
const { t } = this.context
- const { transaction, showCancel, showRetry } = this.props
+ const { transactionGroup, showCancel, showRetry, onCancel, onRetry } = this.props
+ const { primaryTransaction: transaction } = transactionGroup
const { txParams: { to, from } = {} } = transaction
return (
<div className="transaction-list-item-details">
<div className="transaction-list-item-details__header">
- <div>Details</div>
+ <div>{ t('details') }</div>
<div className="transaction-list-item-details__header-buttons">
{
showRetry && (
@@ -87,23 +89,27 @@ export default class TransactionListItemDetails extends PureComponent {
</Tooltip>
</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 className="transaction-list-item-details__body">
+ <div className="transaction-list-item-details__sender-to-recipient-container">
+ <SenderToRecipient
+ variant={FLAT_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
+ transactionGroup={transactionGroup}
+ className="transaction-list-item-details__transaction-activity-log"
+ onCancel={onCancel}
+ onRetry={onRetry}
+ />
+ </div>
</div>
</div>
)
diff --git a/ui/app/components/transaction-list-item/index.scss b/ui/app/components/transaction-list-item/index.scss
index 449974734..9e73a546c 100644
--- a/ui/app/components/transaction-list-item/index.scss
+++ b/ui/app/components/transaction-list-item/index.scss
@@ -117,12 +117,6 @@
}
}
- &__details-container {
- padding: 8px 16px 16px;
- background: #f3f4f7;
- width: 100%;
- }
-
&__expander {
max-height: 0px;
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 696634fe0..ecd8b4cef 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
@@ -18,6 +18,7 @@ export default class TransactionListItem extends PureComponent {
history: PropTypes.object,
methodData: PropTypes.object,
nonceAndDate: PropTypes.string,
+ primaryTransaction: PropTypes.object,
retryTransaction: PropTypes.func,
setSelectedToken: PropTypes.func,
showCancelModal: PropTypes.func,
@@ -26,7 +27,10 @@ export default class TransactionListItem extends PureComponent {
token: PropTypes.object,
tokenData: PropTypes.object,
transaction: PropTypes.object,
+ transactionGroup: PropTypes.object,
value: PropTypes.string,
+ fetchBasicGasAndTimeEstimates: PropTypes.func,
+ fetchGasEstimates: PropTypes.func,
}
state = {
@@ -49,33 +53,48 @@ export default class TransactionListItem extends PureComponent {
this.setState({ showTransactionDetails: !showTransactionDetails })
}
- handleCancel = () => {
- const { transaction: { id, txParams: { gasPrice } } = {}, showCancelModal } = this.props
- showCancelModal(id, gasPrice)
+ handleCancel = id => {
+ const {
+ primaryTransaction: { txParams: { gasPrice } } = {},
+ transaction: { id: initialTransactionId },
+ showCancelModal,
+ } = this.props
+
+ const cancelId = id || initialTransactionId
+ showCancelModal(cancelId, gasPrice)
}
- handleRetry = () => {
+ /**
+ * @name handleRetry
+ * @description Resubmits a transaction. Retrying a transaction within a list of transactions with
+ * the same nonce requires keeping the original value while increasing the gas price of the latest
+ * transaction.
+ * @param {number} id - Transaction id
+ */
+ handleRetry = id => {
const {
- transaction: { txParams: { to } = {} },
+ primaryTransaction: { txParams: { gasPrice } } = {},
+ transaction: { txParams: { to } = {}, id: initialTransactionId },
methodData: { name } = {},
setSelectedToken,
+ retryTransaction,
+ fetchBasicGasAndTimeEstimates,
+ fetchGasEstimates,
} = this.props
if (name === TOKEN_METHOD_TRANSFER) {
setSelectedToken(to)
}
- return this.resubmit()
- }
+ const retryId = id || initialTransactionId
- resubmit () {
- const { transaction: { id }, retryTransaction, history } = this.props
- return retryTransaction(id)
- .then(id => history.push(`${CONFIRM_TRANSACTION_ROUTE}/${id}`))
+ return fetchBasicGasAndTimeEstimates()
+ .then(basicEstimates => fetchGasEstimates(basicEstimates.blockTime))
+ .then(retryTransaction(retryId, gasPrice))
}
renderPrimaryCurrency () {
- const { token, transaction: { txParams: { data } = {} } = {}, value } = this.props
+ const { token, primaryTransaction: { txParams: { data } = {} } = {}, value } = this.props
return token
? (
@@ -113,12 +132,14 @@ export default class TransactionListItem extends PureComponent {
render () {
const {
assetImages,
+ transaction,
methodData,
nonceAndDate,
+ primaryTransaction,
showCancel,
showRetry,
tokenData,
- transaction,
+ transactionGroup,
} = this.props
const { txParams = {} } = transaction
const { showTransactionDetails } = this.state
@@ -151,11 +172,11 @@ export default class TransactionListItem extends PureComponent {
</div>
<TransactionStatus
className="transaction-list-item__status"
- statusKey={getStatusKey(transaction)}
+ statusKey={getStatusKey(primaryTransaction)}
title={(
- (transaction.err && transaction.err.rpc)
- ? transaction.err.rpc.message
- : transaction.err && transaction.err.message
+ (primaryTransaction.err && primaryTransaction.err.rpc)
+ ? primaryTransaction.err.rpc.message
+ : primaryTransaction.err && primaryTransaction.err.message
)}
/>
{ this.renderPrimaryCurrency() }
@@ -168,7 +189,7 @@ export default class TransactionListItem extends PureComponent {
showTransactionDetails && (
<div className="transaction-list-item__details-container">
<TransactionListItemDetails
- transaction={transaction}
+ transactionGroup={transactionGroup}
onRetry={this.handleRetry}
showRetry={showRetry && methodData.done}
onCancel={this.handleCancel}
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 62ed7a73f..e08d3232f 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
@@ -3,36 +3,67 @@ import { withRouter } from 'react-router-dom'
import { compose } from 'recompose'
import withMethodData from '../../higher-order-components/with-method-data'
import TransactionListItem from './transaction-list-item.component'
-import { setSelectedToken, retryTransaction, showModal } from '../../actions'
+import { setSelectedToken, showModal, showSidebar } from '../../actions'
import { hexToDecimal } from '../../helpers/conversions.util'
import { getTokenData } from '../../helpers/transactions.util'
+import { increaseLastGasPrice } from '../../helpers/confirm-transaction/util'
import { formatDate } from '../../util'
+import {
+ fetchBasicGasAndTimeEstimates,
+ fetchGasEstimates,
+ setCustomGasPriceForRetry,
+ setCustomGasLimit,
+} from '../../ducks/gas.duck'
-const mapStateToProps = (state, ownProps) => {
- const { transaction: { txParams: { value, nonce, data } = {}, time } = {} } = ownProps
+const mapDispatchToProps = dispatch => {
+ return {
+ fetchBasicGasAndTimeEstimates: () => dispatch(fetchBasicGasAndTimeEstimates()),
+ fetchGasEstimates: (blockTime) => dispatch(fetchGasEstimates(blockTime)),
+ setSelectedToken: tokenAddress => dispatch(setSelectedToken(tokenAddress)),
+ retryTransaction: (transaction, gasPrice) => {
+ dispatch(setCustomGasPriceForRetry(gasPrice || transaction.txParams.gasPrice))
+ dispatch(setCustomGasLimit(transaction.txParams.gas))
+ dispatch(showSidebar({
+ transitionName: 'sidebar-left',
+ type: 'customize-gas',
+ props: { transaction },
+ }))
+ },
+ showCancelModal: (transactionId, originalGasPrice) => {
+ return dispatch(showModal({ name: 'CANCEL_TRANSACTION', transactionId, originalGasPrice }))
+ },
+ }
+}
+
+const mergeProps = (stateProps, dispatchProps, ownProps) => {
+ const { transactionGroup: { primaryTransaction, initialTransaction } = {} } = ownProps
+ const { retryTransaction, ...restDispatchProps } = dispatchProps
+ const { txParams: { nonce, data } = {}, time } = initialTransaction
+ const { txParams: { value } = {} } = primaryTransaction
const tokenData = data && getTokenData(data)
const nonceAndDate = nonce ? `#${hexToDecimal(nonce)} - ${formatDate(time)}` : formatDate(time)
return {
+ ...stateProps,
+ ...restDispatchProps,
+ ...ownProps,
value,
nonceAndDate,
tokenData,
- }
-}
-
-const mapDispatchToProps = dispatch => {
- return {
- setSelectedToken: tokenAddress => dispatch(setSelectedToken(tokenAddress)),
- retryTransaction: transactionId => dispatch(retryTransaction(transactionId)),
- showCancelModal: (transactionId, originalGasPrice) => {
- return dispatch(showModal({ name: 'CANCEL_TRANSACTION', transactionId, originalGasPrice }))
+ transaction: initialTransaction,
+ primaryTransaction,
+ retryTransaction: (transactionId, gasPrice) => {
+ const { transactionGroup: { transactions = [] } } = ownProps
+ const transaction = transactions.find(tx => tx.id === transactionId) || {}
+ const increasedGasPrice = increaseLastGasPrice(gasPrice)
+ retryTransaction(transaction, increasedGasPrice)
},
}
}
export default compose(
withRouter,
- connect(mapStateToProps, mapDispatchToProps),
+ connect(null, mapDispatchToProps, mergeProps),
withMethodData,
)(TransactionListItem)
diff --git a/ui/app/components/transaction-list/transaction-list.component.js b/ui/app/components/transaction-list/transaction-list.component.js
index eef60186d..c1e3b3d1c 100644
--- a/ui/app/components/transaction-list/transaction-list.component.js
+++ b/ui/app/components/transaction-list/transaction-list.component.js
@@ -12,13 +12,11 @@ export default class TransactionList extends PureComponent {
static defaultProps = {
pendingTransactions: [],
completedTransactions: [],
- transactionToRetry: {},
}
static propTypes = {
pendingTransactions: PropTypes.array,
completedTransactions: PropTypes.array,
- transactionToRetry: PropTypes.object,
selectedToken: PropTypes.object,
updateNetworkNonce: PropTypes.func,
assetImages: PropTypes.object,
@@ -37,26 +35,34 @@ export default class TransactionList extends PureComponent {
}
}
- shouldShowRetry = transaction => {
- const { transactionToRetry } = this.props
- const { id, submittedTime } = transaction
- return id === transactionToRetry.id && Date.now() - submittedTime > 30000
+ shouldShowRetry = (transactionGroup, isEarliestNonce) => {
+ const { transactions = [], hasRetried } = transactionGroup
+ const [earliestTransaction = {}] = transactions
+ const { submittedTime } = earliestTransaction
+ return Date.now() - submittedTime > 30000 && isEarliestNonce && !hasRetried
+ }
+
+ shouldShowCancel (transactionGroup) {
+ const { hasCancelled } = transactionGroup
+ return !hasCancelled
}
renderTransactions () {
const { t } = this.context
const { pendingTransactions = [], completedTransactions = [] } = this.props
+ const pendingLength = pendingTransactions.length
+
return (
<div className="transaction-list__transactions">
{
- pendingTransactions.length > 0 && (
+ pendingLength > 0 && (
<div className="transaction-list__pending-transactions">
<div className="transaction-list__header">
{ `${t('queue')} (${pendingTransactions.length})` }
</div>
{
- pendingTransactions.map((transaction, index) => (
- this.renderTransaction(transaction, index, true)
+ pendingTransactions.map((transactionGroup, index) => (
+ this.renderTransaction(transactionGroup, index, true, index === pendingLength - 1)
))
}
</div>
@@ -68,8 +74,8 @@ export default class TransactionList extends PureComponent {
</div>
{
completedTransactions.length > 0
- ? completedTransactions.map((transaction, index) => (
- this.renderTransaction(transaction, index)
+ ? completedTransactions.map((transactionGroup, index) => (
+ this.renderTransaction(transactionGroup, index)
))
: this.renderEmpty()
}
@@ -78,21 +84,22 @@ export default class TransactionList extends PureComponent {
)
}
- renderTransaction (transaction, index, showCancel) {
+ renderTransaction (transactionGroup, index, isPendingTx = false, isEarliestNonce = false) {
const { selectedToken, assetImages } = this.props
+ const { transactions = [] } = transactionGroup
- return transaction.key === TRANSACTION_TYPE_SHAPESHIFT
+ return transactions[0].key === TRANSACTION_TYPE_SHAPESHIFT
? (
<ShapeShiftTransactionListItem
- { ...transaction }
+ { ...transactions[0] }
key={`shapeshift${index}`}
/>
) : (
<TransactionListItem
- transaction={transaction}
- key={transaction.id}
- showRetry={this.shouldShowRetry(transaction)}
- showCancel={showCancel}
+ transactionGroup={transactionGroup}
+ key={`${transactionGroup.nonce}:${index}`}
+ showRetry={isPendingTx && this.shouldShowRetry(transactionGroup, isEarliestNonce)}
+ showCancel={isPendingTx && this.shouldShowCancel(transactionGroup)}
token={selectedToken}
assetImages={assetImages}
/>
diff --git a/ui/app/components/transaction-list/transaction-list.container.js b/ui/app/components/transaction-list/transaction-list.container.js
index 2e946c67d..e70ca15c5 100644
--- a/ui/app/components/transaction-list/transaction-list.container.js
+++ b/ui/app/components/transaction-list/transaction-list.container.js
@@ -3,24 +3,17 @@ import { withRouter } from 'react-router-dom'
import { compose } from 'recompose'
import TransactionList from './transaction-list.component'
import {
- pendingTransactionsSelector,
- submittedPendingTransactionsSelector,
- completedTransactionsSelector,
+ nonceSortedCompletedTransactionsSelector,
+ nonceSortedPendingTransactionsSelector,
} from '../../selectors/transactions'
import { getSelectedAddress, getAssetImages } from '../../selectors'
import { selectedTokenSelector } from '../../selectors/tokens'
-import { getLatestSubmittedTxWithNonce } from '../../helpers/transactions.util'
import { updateNetworkNonce } from '../../actions'
const mapStateToProps = state => {
- const pendingTransactions = pendingTransactionsSelector(state)
- const submittedPendingTransactions = submittedPendingTransactionsSelector(state)
- const networkNonce = state.appState.networkNonce
-
return {
- completedTransactions: completedTransactionsSelector(state),
- pendingTransactions,
- transactionToRetry: getLatestSubmittedTxWithNonce(submittedPendingTransactions, networkNonce),
+ completedTransactions: nonceSortedCompletedTransactionsSelector(state),
+ pendingTransactions: nonceSortedPendingTransactionsSelector(state),
selectedToken: selectedTokenSelector(state),
selectedAddress: getSelectedAddress(state),
assetImages: getAssetImages(state),
diff --git a/ui/app/components/transaction-status/index.scss b/ui/app/components/transaction-status/index.scss
index 26a1f5d38..e7daafeef 100644
--- a/ui/app/components/transaction-status/index.scss
+++ b/ui/app/components/transaction-status/index.scss
@@ -1,6 +1,6 @@
.transaction-status {
height: 26px;
- width: 81px;
+ width: 84px;
border-radius: 4px;
background-color: #f0f0f0;
color: #5e6064;
@@ -12,22 +12,34 @@
@media screen and (max-width: $break-small) {
height: 16px;
- width: 70px;
+ width: 72px;
font-size: .5rem;
}
&--confirmed {
background-color: #eafad7;
color: #609a1c;
+
+ .transaction-status__transaction-count {
+ border: 1px solid #609a1c;
+ }
}
&--approved, &--submitted {
background-color: #FFF2DB;
color: #CA810A;
+
+ .transaction-status__transaction-count {
+ border: 1px solid #CA810A;
+ }
}
&--failed {
background: lighten($monzo, 56%);
color: $monzo;
+
+ .transaction-status__transaction-count {
+ border: 1px solid $monzo;
+ }
}
}
diff --git a/ui/app/components/transaction-status/tests/transaction-status.component.test.js b/ui/app/components/transaction-status/tests/transaction-status.component.test.js
index 9e3bffe4f..f4ddc9206 100644
--- a/ui/app/components/transaction-status/tests/transaction-status.component.test.js
+++ b/ui/app/components/transaction-status/tests/transaction-status.component.test.js
@@ -15,9 +15,8 @@ describe('TransactionStatus Component', () => {
)
assert.ok(wrapper)
- const tooltipProps = wrapper.find(Tooltip).props()
- assert.equal(tooltipProps.children, 'APPROVED')
- assert.equal(tooltipProps.title, 'test-title')
+ assert.equal(wrapper.text(), 'APPROVED')
+ assert.equal(wrapper.find(Tooltip).props().title, 'test-title')
})
it('should render SUBMITTED properly', () => {
@@ -29,7 +28,6 @@ describe('TransactionStatus Component', () => {
)
assert.ok(wrapper)
- const tooltipProps = wrapper.find(Tooltip).props()
- assert.equal(tooltipProps.children, 'PENDING')
+ assert.equal(wrapper.text(), 'PENDING')
})
})
diff --git a/ui/app/components/transaction-status/transaction-status.component.js b/ui/app/components/transaction-status/transaction-status.component.js
index 0d47d7868..28544d2cd 100644
--- a/ui/app/components/transaction-status/transaction-status.component.js
+++ b/ui/app/components/transaction-status/transaction-status.component.js
@@ -11,6 +11,7 @@ import {
CONFIRMED_STATUS,
FAILED_STATUS,
DROPPED_STATUS,
+ CANCELLED_STATUS,
} from '../../constants/transactions'
const statusToClassNameHash = {
@@ -22,6 +23,7 @@ const statusToClassNameHash = {
[CONFIRMED_STATUS]: 'transaction-status--confirmed',
[FAILED_STATUS]: 'transaction-status--failed',
[DROPPED_STATUS]: 'transaction-status--dropped',
+ [CANCELLED_STATUS]: 'transaction-status--failed',
}
const statusToTextHash = {
@@ -49,7 +51,10 @@ export default class TransactionStatus extends PureComponent {
return (
<div className={classnames('transaction-status', className, statusToClassNameHash[statusKey])}>
- <Tooltip position="top" title={title}>
+ <Tooltip
+ position="top"
+ title={title}
+ >
{ statusText }
</Tooltip>
</div>