diff options
Diffstat (limited to 'ui/app/components')
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> |