diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index f6cf7cf69..89abb0e76 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -128,6 +128,9 @@ "cancellationGasFee": { "message": "Cancellation Gas Fee" }, + "cancelN": { + "message": "Cancel all $1 transactions" + }, "classicInterface": { "message": "Use classic interface" }, @@ -781,6 +784,18 @@ "refundAddress": { "message": "Your Refund Address" }, + "reject": { + "message": "Reject" + }, + "rejectAll": { + "message": "Reject All" + }, + "rejectTxsN": { + "message": "Reject $1 transactions" + }, + "rejectTxsDescription": { + "message": "You are about to batch reject $1 transactions." + }, "rejected": { "message": "Rejected" }, diff --git a/ui/app/actions.js b/ui/app/actions.js index 66dc80509..8f6586139 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -167,6 +167,7 @@ var actions = { updateTransaction, updateAndApproveTx, cancelTx: cancelTx, + cancelTxs, completedTx: completedTx, txError: txError, nextTx: nextTx, @@ -1302,6 +1303,47 @@ function cancelTx (txData) { } } +/** + * Cancels all of the given transactions + * @param {Array} txDataList a list of tx data objects + * @return {function(*): Promise} + */ +function cancelTxs (txDataList) { + return async (dispatch, getState) => { + dispatch(actions.showLoadingIndication()) + const txIds = txDataList.map(({id}) => id) + const cancellations = txIds.map((id) => new Promise((resolve, reject) => { + background.cancelTransaction(id, (err) => { + if (err) { + return reject(err) + } + + resolve() + }) + })) + + await Promise.all(cancellations) + const newState = await updateMetamaskStateFromBackground() + dispatch(actions.updateMetamaskState(newState)) + dispatch(actions.clearSend()) + + txIds.forEach((id) => { + dispatch(actions.completedTx(id)) + }) + + dispatch(actions.hideLoadingIndication()) + + if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION) { + return global.platform.closeCurrentWindow() + } + } +} + +/** + * @deprecated + * @param {Array} txsData + * @return {Function} + */ function cancelAllTx (txsData) { return (dispatch) => { txsData.forEach((txData, i) => { diff --git a/ui/app/components/confirm-page-container/confirm-page-container.component.js b/ui/app/components/confirm-page-container/confirm-page-container.component.js index b1582051e..36d5a1f58 100644 --- a/ui/app/components/confirm-page-container/confirm-page-container.component.js +++ b/ui/app/components/confirm-page-container/confirm-page-container.component.js @@ -41,7 +41,9 @@ export default class ConfirmPageContainer extends Component { assetImage: PropTypes.string, summaryComponent: PropTypes.node, warning: PropTypes.string, + unapprovedTxCount: PropTypes.number, // Footer + onCancelAll: PropTypes.func, onCancel: PropTypes.func, onSubmit: PropTypes.func, disabled: PropTypes.bool, @@ -67,10 +69,12 @@ export default class ConfirmPageContainer extends Component { summaryComponent, detailsComponent, dataComponent, + onCancelAll, onCancel, onSubmit, identiconAddress, nonce, + unapprovedTxCount, assetImage, warning, } = this.props @@ -112,11 +116,18 @@ export default class ConfirmPageContainer extends Component { } onCancel()} + cancelText={this.context.t('reject')} onSubmit={() => onSubmit()} submitText={this.context.t('confirm')} submitButtonType="confirm" disabled={disabled} - /> + > + {unapprovedTxCount > 1 && ( + onCancelAll()}> + {this.context.t('rejectTxsN', [unapprovedTxCount])} + + )} + ) } diff --git a/ui/app/components/modals/modal.js b/ui/app/components/modals/modal.js index 6054002c8..15ca9deaa 100644 --- a/ui/app/components/modals/modal.js +++ b/ui/app/components/modals/modal.js @@ -28,6 +28,7 @@ import ConfirmCustomizeGasModal from './customize-gas' import CancelTransaction from './cancel-transaction' import WelcomeBeta from './welcome-beta' import TransactionDetails from './transaction-details' +import RejectTransactions from './reject-transactions' const modalContainerBaseStyle = { transform: 'translate3d(-50%, 0, 0px)', @@ -378,6 +379,19 @@ const MODALS = { }, }, + REJECT_TRANSACTIONS: { + contents: h(RejectTransactions), + mobileModalStyle: { + ...modalContainerMobileStyle, + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + }, + contentStyle: { + borderRadius: '8px', + }, + }, + DEFAULT: { contents: [], mobileModalStyle: {}, diff --git a/ui/app/components/modals/reject-transactions/index.js b/ui/app/components/modals/reject-transactions/index.js new file mode 100644 index 000000000..fcdc372b6 --- /dev/null +++ b/ui/app/components/modals/reject-transactions/index.js @@ -0,0 +1 @@ +export { default } from './reject-transactions.container' diff --git a/ui/app/components/modals/reject-transactions/index.scss b/ui/app/components/modals/reject-transactions/index.scss new file mode 100644 index 000000000..753466883 --- /dev/null +++ b/ui/app/components/modals/reject-transactions/index.scss @@ -0,0 +1,6 @@ +.reject-transactions { + &__description { + text-align: center; + font-size: .875rem; + } +} diff --git a/ui/app/components/modals/reject-transactions/reject-transactions.component.js b/ui/app/components/modals/reject-transactions/reject-transactions.component.js new file mode 100644 index 000000000..60b259bdc --- /dev/null +++ b/ui/app/components/modals/reject-transactions/reject-transactions.component.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types' +import React, { PureComponent } from 'react' +import Modal from '../../modal' + +export default class RejectTransactionsModal extends PureComponent { + static contextTypes = { + t: PropTypes.func.isRequired, + } + + static propTypes = { + onSubmit: PropTypes.func.isRequired, + hideModal: PropTypes.func.isRequired, + unapprovedTxCount: PropTypes.number.isRequired, + } + + onSubmit = async () => { + const { onSubmit, hideModal } = this.props + + await onSubmit() + hideModal() + } + + render () { + const { t } = this.context + const { hideModal, unapprovedTxCount } = this.props + + return ( + +
+
+ { t('rejectTxsDescription', [unapprovedTxCount]) } +
+
+
+ ) + } +} diff --git a/ui/app/components/modals/reject-transactions/reject-transactions.container.js b/ui/app/components/modals/reject-transactions/reject-transactions.container.js new file mode 100644 index 000000000..81e98d3ff --- /dev/null +++ b/ui/app/components/modals/reject-transactions/reject-transactions.container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import RejectTransactionsModal from './reject-transactions.component' +import withModalProps from '../../../higher-order-components/with-modal-props' + +const mapStateToProps = (state, ownProps) => { + const { unapprovedTxCount } = ownProps + + return { + unapprovedTxCount, + } +} + +export default compose( + withModalProps, + connect(mapStateToProps), +)(RejectTransactionsModal) diff --git a/ui/app/components/page-container/index.scss b/ui/app/components/page-container/index.scss index 61434cbcf..6742e3082 100644 --- a/ui/app/components/page-container/index.scss +++ b/ui/app/components/page-container/index.scss @@ -43,16 +43,39 @@ &__footer { display: flex; - flex-flow: row; + flex-flow: column; justify-content: center; border-top: 1px solid $geyser; - padding: 16px; flex: 0 0 auto; .btn-default, .btn-confirm { font-size: 1rem; } + + header { + display: flex; + flex-flow: row; + justify-content: center; + padding: 16px; + flex: 0 0 auto; + } + + footer { + display: flex; + flex-flow: row; + justify-content: space-around; + padding: 0 16px 16px; + flex: 0 0 auto; + + a, a:hover { + text-decoration: none; + cursor: pointer; + font-size: 0.75rem; + text-transform: uppercase; + color: #2f9ae0; + } + } } &__footer-button { 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 3d15df294..773fe1f56 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 @@ -5,6 +5,7 @@ import Button from '../../button' export default class PageContainerFooter extends Component { static propTypes = { + children: PropTypes.node, onCancel: PropTypes.func, cancelText: PropTypes.string, onSubmit: PropTypes.func, @@ -19,6 +20,7 @@ export default class PageContainerFooter extends Component { render () { const { + children, onCancel, cancelText, onSubmit, @@ -30,24 +32,32 @@ export default class PageContainerFooter extends Component { return (
- +
+ - + +
+ + {children && ( +
+ {children} +
+ )}
) diff --git a/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js b/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js index 5e5dbf00b..64efabab0 100644 --- a/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js +++ b/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js @@ -25,6 +25,17 @@ describe('Page Footer', () => { assert.equal(wrapper.find('.page-container__footer').length, 1) }) + it('should render a footer inside page-container__footer when given children', () => { + const wrapper = shallow( + +
Works
+
, + { context: { t: sinon.spy((k) => `[${k}]`) } } + ) + + assert.equal(wrapper.find('.page-container__footer footer').length, 1) + }) + it('renders two button components', () => { assert.equal(wrapper.find(Button).length, 2) }) @@ -65,5 +76,4 @@ describe('Page Footer', () => { assert.equal(onSubmit.callCount, 1) }) }) - }) 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 40d8faf50..707dad62d 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 @@ -22,6 +22,7 @@ export default class ConfirmTransactionBase extends Component { // Redux props balance: PropTypes.string, cancelTransaction: PropTypes.func, + cancelAllTransactions: PropTypes.func, clearConfirmTransaction: PropTypes.func, clearSend: PropTypes.func, conversionRate: PropTypes.number, @@ -43,12 +44,14 @@ export default class ConfirmTransactionBase extends Component { sendTransaction: PropTypes.func, showCustomizeGasModal: PropTypes.func, showTransactionConfirmedModal: PropTypes.func, + showRejectTransactionsConfirmationModal: PropTypes.func, toAddress: PropTypes.string, tokenData: PropTypes.object, tokenProps: PropTypes.object, toName: PropTypes.string, transactionStatus: PropTypes.string, txData: PropTypes.object, + unapprovedTxCount: PropTypes.number, // Component props action: PropTypes.string, contentComponent: PropTypes.node, @@ -249,6 +252,25 @@ export default class ConfirmTransactionBase extends Component { onEdit({ txData, tokenData, tokenProps }) } + handleCancelAll () { + const { + cancelAllTransactions, + clearConfirmTransaction, + history, + showRejectTransactionsConfirmationModal, + unapprovedTxCount, + } = this.props + + showRejectTransactionsConfirmationModal({ + unapprovedTxCount, + async onSubmit () { + await cancelAllTransactions() + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }, + }) + } + handleCancel () { const { onCancel, txData, cancelTransaction, history, clearConfirmTransaction } = this.props @@ -314,6 +336,7 @@ export default class ConfirmTransactionBase extends Component { nonce, assetImage, warning, + unapprovedTxCount, } = this.props const { submitting, submitError } = this.state @@ -337,6 +360,7 @@ export default class ConfirmTransactionBase extends Component { dataComponent={this.renderData()} contentComponent={contentComponent} nonce={nonce} + unapprovedTxCount={unapprovedTxCount} assetImage={assetImage} identiconAddress={identiconAddress} errorMessage={errorMessage || submitError} @@ -344,6 +368,7 @@ export default class ConfirmTransactionBase extends Component { warning={warning} disabled={!propsValid || !valid || submitting} onEdit={() => this.handleEdit()} + onCancelAll={() => this.handleCancelAll()} onCancel={() => this.handleCancel()} onSubmit={() => this.handleSubmit()} /> 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 ae31eba17..b34067686 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 @@ -8,7 +8,7 @@ import { clearConfirmTransaction, updateGasAndCalculate, } from '../../../ducks/confirm-transaction.duck' -import { clearSend, cancelTx, updateAndApproveTx, showModal } from '../../../actions' +import { clearSend, cancelTx, cancelTxs, updateAndApproveTx, showModal } from '../../../actions' import { INSUFFICIENT_FUNDS_ERROR_KEY, GAS_LIMIT_TOO_LOW_ERROR_KEY, @@ -17,7 +17,7 @@ import { getHexGasTotal } from '../../../helpers/confirm-transaction/util' import { isBalanceSufficient } from '../../send/send.utils' import { conversionGreaterThan } from '../../../conversion-util' import { MIN_GAS_LIMIT_DEC } from '../../send/send.constants' -import { addressSlicer } from '../../../util' +import { addressSlicer, valuesFor } from '../../../util' const casedContractMap = Object.keys(contractMap).reduce((acc, base) => { return { @@ -53,6 +53,8 @@ const mapStateToProps = (state, props) => { selectedAddress, selectedAddressTxList, assetImages, + network, + unapprovedTxs, } = metamask const assetImage = assetImages[txParamsToAddress] const { balance } = accounts[selectedAddress] @@ -67,6 +69,12 @@ const mapStateToProps = (state, props) => { const transaction = R.find(({ id }) => id === transactionId)(selectedAddressTxList) const transactionStatus = transaction ? transaction.status : '' + const currentNetworkUnapprovedTxs = R.filter( + ({ metamaskNetworkId }) => metamaskNetworkId === network, + valuesFor(unapprovedTxs), + ) + const unapprovedTxCount = currentNetworkUnapprovedTxs.length + return { balance, fromAddress, @@ -90,6 +98,8 @@ const mapStateToProps = (state, props) => { transactionStatus, nonce, assetImage, + unapprovedTxs, + unapprovedTxCount, } } @@ -106,7 +116,11 @@ const mapDispatchToProps = dispatch => { updateGasAndCalculate: ({ gasLimit, gasPrice }) => { return dispatch(updateGasAndCalculate({ gasLimit, gasPrice })) }, + showRejectTransactionsConfirmationModal: ({ onSubmit, unapprovedTxCount }) => { + return dispatch(showModal({ name: 'REJECT_TRANSACTIONS', onSubmit, unapprovedTxCount })) + }, cancelTransaction: ({ id }) => dispatch(cancelTx({ id })), + cancelAllTransactions: (txList) => dispatch(cancelTxs(txList)), sendTransaction: txData => dispatch(updateAndApproveTx(txData)), } } @@ -156,8 +170,9 @@ const getValidateEditGas = ({ balance, conversionRate, txData }) => { } const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { balance, conversionRate, txData } = stateProps + const { balance, conversionRate, txData, unapprovedTxs } = stateProps const { + cancelAllTransactions: dispatchCancelAllTransactions, showCustomizeGasModal: dispatchShowCustomizeGasModal, updateGasAndCalculate: dispatchUpdateGasAndCalculate, ...otherDispatchProps @@ -174,6 +189,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { onSubmit: txData => dispatchUpdateGasAndCalculate(txData), validate: validateEditGas, }), + cancelAllTransactions: () => dispatchCancelAllTransactions(valuesFor(unapprovedTxs)), } }