diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnerComponent.tsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnerComponent.tsx index c4ca0a60..bd381a32 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnerComponent.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnerComponent.tsx @@ -8,6 +8,7 @@ import ConfirmSmallFilledCircle from './assets/confirm-small-filled.svg' import ConfirmSmallGreenCircle from './assets/confirm-small-green.svg' import ConfirmSmallGreyCircle from './assets/confirm-small-grey.svg' import ConfirmSmallRedCircle from './assets/confirm-small-red.svg' +import PendingSmallYellowCircle from './assets/confirm-small-yellow.svg' import { styles } from './style' import EtherscanLink from 'src/components/EtherscanLink' @@ -32,6 +33,8 @@ const OwnerComponent = ({ onTxExecute, onTxReject, owner, + pendingAcceptAction, + pendingRejectAction, showConfirmBtn, showExecuteBtn, showExecuteRejectBtn, @@ -43,18 +46,110 @@ const OwnerComponent = ({ const [imgCircle, setImgCircle] = React.useState(ConfirmSmallGreyCircle) React.useMemo(() => { + if (pendingAcceptAction || pendingRejectAction) { + setImgCircle(PendingSmallYellowCircle) + return + } if (confirmed) { setImgCircle(isCancelTx ? CancelSmallFilledCircle : ConfirmSmallFilledCircle) - } else if (thresholdReached || executor) { - setImgCircle(isCancelTx ? ConfirmSmallRedCircle : ConfirmSmallGreenCircle) + return } - }, [confirmed, thresholdReached, executor, isCancelTx]) + if (thresholdReached || executor) { + setImgCircle(isCancelTx ? ConfirmSmallRedCircle : ConfirmSmallGreenCircle) + return + } + setImgCircle(ConfirmSmallGreyCircle) + }, [confirmed, thresholdReached, executor, isCancelTx, pendingAcceptAction, pendingRejectAction]) - const getTimelineLine = () => (isCancelTx ? classes.verticalLineCancel : classes.verticalLineDone) + const getTimelineLine = () => { + if (pendingAcceptAction || pendingRejectAction) { + return classes.verticalPendingAction + } + if (isCancelTx) { + return classes.verticalLineCancel + } + return classes.verticalLineDone + } + + const confirmButton = () => { + if (pendingRejectAction) { + return null + } + if (pendingAcceptAction) { + return Pending + } + return ( + <> + {showConfirmBtn && ( + + )} + {showExecuteBtn && ( + + )} + + ) + } + + const rejectButton = () => { + if (pendingRejectAction) { + return Pending + } + if (pendingAcceptAction) { + return null + } + return ( + <> + {showRejectBtn && ( + + )} + {showExecuteRejectBtn && ( + + )} + + ) + } return ( -
+
@@ -66,61 +161,7 @@ const OwnerComponent = ({ - {owner === userAddress && ( - - {isCancelTx ? ( - <> - {showRejectBtn && ( - - )} - {showExecuteRejectBtn && ( - - )} - - ) : ( - <> - {showConfirmBtn && ( - - )} - {showExecuteBtn && ( - - )} - - )} - - )} + {owner === userAddress && {isCancelTx ? rejectButton() : confirmButton()}} {owner === executor && Executor} ) diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnersList.tsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnersList.tsx index 385da37e..8cd107f9 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnersList.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnersList.tsx @@ -38,7 +38,7 @@ const OwnersList = ({ userAddress={userAddress} /> ))} - {ownersUnconfirmed.map((owner) => ( + {ownersUnconfirmed.map(({ hasPendingAcceptActions, hasPendingRejectActions, owner }) => ( { const confirmationsEntry = tx.confirmations.find((conf) => conf.owner === owner.address) if (!confirmationsEntry) { - ownersNotConfirmed.push(owner.address) + ownersWithNoConfirmations.push(owner.address) } if (confirmationsEntry && confirmationsEntry.owner === userAddress) { currentUserNotConfirmed = false } }) - return [ownersNotConfirmed, currentUserNotConfirmed] + const confirmationPendingActions = tx.ownersWithPendingActions.get('confirm') + const confirmationRejectActions = tx.ownersWithPendingActions.get('reject') + + const ownersWithNoConfirmationsSorted = ownersWithNoConfirmations + .map((owner) => ({ + hasPendingAcceptActions: confirmationPendingActions.includes(owner), + hasPendingRejectActions: confirmationRejectActions.includes(owner), + owner, + })) + // Reorders the list of unconfirmed owners, owners with pendingActions should be first + .sort((ownerA, ownerB) => { + // If the first owner has pending actions, A should be before B + if (ownerA.hasPendingRejectActions || ownerA.hasPendingAcceptActions) { + return -1 + } + // The first owner has not pending actions but the second yes, B should be before A + if (ownerB.hasPendingRejectActions || ownerB.hasPendingAcceptActions) { + return 1 + } + // Otherwise do not change order + return 0 + }) + + return [ownersWithNoConfirmationsSorted, currentUserNotConfirmed] } const OwnersColumn = ({ diff --git a/src/routes/safe/store/actions/createTransaction.ts b/src/routes/safe/store/actions/createTransaction.ts index 42de4219..01c6046a 100644 --- a/src/routes/safe/store/actions/createTransaction.ts +++ b/src/routes/safe/store/actions/createTransaction.ts @@ -1,5 +1,6 @@ import { push } from 'connected-react-router' -import { List, Map, fromJS } from 'immutable' +import { List, Map } from 'immutable' +import { batch } from 'react-redux' import semverSatisfies from 'semver/functions/satisfies' import { onboardUser } from 'src/components/ConnectButton' @@ -22,30 +23,41 @@ import { getLastTx, getNewTxNonce, shouldExecuteTransaction } from 'src/routes/s import { getErrorMessage } from 'src/test/utils/ethereumErrors' import { makeConfirmation } from '../models/confirmation' import fetchTransactions from './transactions/fetchTransactions' +import { safeTransactionsSelector } from 'src/routes/safe/store/selectors' -export const removeTxFromStore = (tx, safeAddress, dispatch) => { +export const removeTxFromStore = (tx, safeAddress, dispatch, state) => { if (tx.isCancellationTx) { - dispatch(removeCancellationTransaction({ safeAddress, transaction: tx })) + const newTxStatus = 'awaiting_confirmations' + const transactions = safeTransactionsSelector(state) + const txsToUpdate = transactions + .filter((transaction) => Number(transaction.nonce) === Number(tx.nonce)) + .withMutations((list) => list.map((tx) => tx.set('status', newTxStatus))) + + batch(() => { + dispatch(addOrUpdateTransactions({ safeAddress, transactions: txsToUpdate })) + dispatch(removeCancellationTransaction({ safeAddress, transaction: tx })) + }) } else { dispatch(removeTransaction({ safeAddress, transaction: tx })) } } -export const storeTx = async (tx, safeAddress, dispatch) => { +export const storeTx = async (tx, safeAddress, dispatch, state) => { if (tx.isCancellationTx) { - dispatch( - addOrUpdateCancellationTransactions({ - safeAddress, - transactions: Map({ [`${tx.nonce}`]: tx }), - }), - ) + const newTxStatus = tx.isExecuted ? 'cancelled' : tx.status === 'pending' ? 'pending' : 'awaiting_confirmations' + const transactions = safeTransactionsSelector(state) + const txsToUpdate = transactions + .filter((transaction) => Number(transaction.nonce) === Number(tx.nonce)) + .withMutations((list) => + list.map((tx) => tx.set('status', newTxStatus).set('cancelled', newTxStatus === 'cancelled')), + ) + + batch(() => { + dispatch(addOrUpdateCancellationTransactions({ safeAddress, transactions: Map({ [`${tx.nonce}`]: tx }) })) + dispatch(addOrUpdateTransactions({ safeAddress, transactions: txsToUpdate })) + }) } else { - dispatch( - addOrUpdateTransactions({ - safeAddress, - transactions: List([tx]), - }), - ) + dispatch(addOrUpdateTransactions({ safeAddress, transactions: List([tx]) })) } } @@ -151,15 +163,26 @@ const createTransaction = ({ pendingExecutionKey = showSnackbar(notificationsQueue.pendingExecution, enqueueSnackbar, closeSnackbar) - await Promise.all([saveTxToHistory({ ...txArgs, txHash, origin }), storeTx(mockedTx, safeAddress, dispatch)]) + await Promise.all([ + saveTxToHistory({ ...txArgs, txHash, origin }), + storeTx( + mockedTx.updateIn( + ['ownersWithPendingActions', mockedTx.isCancellationTx ? 'reject' : 'confirm'], + (previous) => previous.push(from), + ), + safeAddress, + dispatch, + state, + ), + ]) dispatch(fetchTransactions(safeAddress)) } catch (e) { - removeTxFromStore(mockedTx, safeAddress, dispatch) + removeTxFromStore(mockedTx, safeAddress, dispatch, state) } }) .on('error', (error) => { closeSnackbar(pendingExecutionKey) - removeTxFromStore(mockedTx, safeAddress, dispatch) + removeTxFromStore(mockedTx, safeAddress, dispatch, state) console.error('Tx error: ', error) }) .then(async (receipt) => { @@ -188,9 +211,16 @@ const createTransaction = ({ : mockedTx.set('status', 'awaiting_confirmations') await storeTx( - toStoreTx.set('confirmations', fromJS([makeConfirmation({ owner: from })])), + toStoreTx.withMutations((record) => { + record + .set('confirmations', List([makeConfirmation({ owner: from })])) + .updateIn(['ownersWithPendingActions', toStoreTx.isCancellationTx ? 'reject' : 'confirm'], (previous) => + previous.pop(from), + ) + }), safeAddress, dispatch, + state, ) dispatch(fetchTransactions(safeAddress)) diff --git a/src/routes/safe/store/actions/processTransaction.ts b/src/routes/safe/store/actions/processTransaction.ts index fb7b7ab8..cf708ff8 100644 --- a/src/routes/safe/store/actions/processTransaction.ts +++ b/src/routes/safe/store/actions/processTransaction.ts @@ -117,18 +117,28 @@ const processTransaction = ({ pendingExecutionKey = showSnackbar(notificationsQueue.pendingExecution, enqueueSnackbar, closeSnackbar) try { - await saveTxToHistory({ ...txArgs, txHash }) - await storeTx(mockedTx, safeAddress, dispatch) + await Promise.all([ + saveTxToHistory({ ...txArgs, txHash }), + storeTx( + mockedTx.updateIn( + ['ownersWithPendingActions', mockedTx.isCancellationTx ? 'reject' : 'confirm'], + (previous) => previous.push(from), + ), + safeAddress, + dispatch, + state, + ), + ]) dispatch(fetchTransactions(safeAddress)) } catch (e) { closeSnackbar(pendingExecutionKey) - await storeTx(tx, safeAddress, dispatch) + await storeTx(tx, safeAddress, dispatch, state) console.error(e) } }) .on('error', (error) => { closeSnackbar(pendingExecutionKey) - storeTx(mockedTx.set('isSuccessful', false), safeAddress, dispatch) + storeTx(tx, safeAddress, dispatch, state) console.error('Processing transaction error: ', error) }) .then(async (receipt) => { @@ -159,9 +169,16 @@ const processTransaction = ({ : mockedTx.set('status', 'awaiting_confirmations') await storeTx( - toStoreTx.set('confirmations', fromJS([...tx.confirmations, makeConfirmation({ owner: from })])), + toStoreTx.withMutations((record) => { + record + .set('confirmations', fromJS([...tx.confirmations, makeConfirmation({ owner: from })])) + .updateIn(['ownersWithPendingActions', toStoreTx.isCancellationTx ? 'reject' : 'confirm'], (previous) => + previous.pop(from), + ) + }), safeAddress, dispatch, + state, ) dispatch(fetchTransactions(safeAddress)) diff --git a/src/routes/safe/store/models/transaction.ts b/src/routes/safe/store/models/transaction.ts index da1fb3ad..0968937d 100644 --- a/src/routes/safe/store/models/transaction.ts +++ b/src/routes/safe/store/models/transaction.ts @@ -1,4 +1,4 @@ -import { List, Record } from 'immutable' +import { List, Map, Record } from 'immutable' import { ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses' @@ -22,6 +22,8 @@ export type TransactionStatus = | 'awaiting_execution' | 'pending' +export type PendingActionType = 'Confirm' | 'Reject' + export type TransactionProps = { baseGas: number blockNumber?: number | null @@ -48,6 +50,7 @@ export type TransactionProps = { nonce?: number | null operation: number origin: string | null + ownersWithPendingActions: Map> recipient: string refundParams: any refundReceiver: string @@ -86,6 +89,7 @@ export const makeTransaction = Record({ nonce: 0, operation: 0, origin: null, + ownersWithPendingActions: Map({ confirm: List([]), reject: List([]) }), recipient: '', refundParams: null, refundReceiver: ZERO_ADDRESS,