From be539348a5778a0282795d10ec39ff9d2a50e580 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 22 Nov 2023 20:38:50 +0100 Subject: [PATCH] feat: pending recoveries in dashboard widget --- .../PendingTxs/PendingRecoveryListItem.tsx | 51 +++++++++++++ .../PendingTxs/PendingTxList.test.ts | 71 +++++++++++++++++++ .../PendingTxs/PendingTxListItem.tsx | 44 ++++++------ .../dashboard/PendingTxs/PendingTxsList.tsx | 70 ++++++++++++++---- .../dashboard/PendingTxs/styles.module.css | 15 ++-- .../recovery/RecoveryInfo/index.tsx | 6 +- .../recovery/RecoverySummary/index.tsx | 8 +-- .../tx-flow/common/OwnerList/index.tsx | 2 +- 8 files changed, 215 insertions(+), 52 deletions(-) create mode 100644 src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx create mode 100644 src/components/dashboard/PendingTxs/PendingTxList.test.ts diff --git a/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx b/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx new file mode 100644 index 0000000000..35af53197d --- /dev/null +++ b/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx @@ -0,0 +1,51 @@ +import Link from 'next/link' +import { useMemo } from 'react' +import { useRouter } from 'next/router' +import { ChevronRight } from '@mui/icons-material' +import { Box } from '@mui/material' +import type { ReactElement } from 'react' + +import { RecoveryInfo } from '@/components/recovery/RecoveryInfo' +import { RecoveryStatus } from '@/components/recovery/RecoveryStatus' +import { RecoveryType } from '@/components/recovery/RecoveryType' +import { AppRoutes } from '@/config/routes' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +import css from './styles.module.css' + +export function PendingRecoveryListItem({ transaction }: { transaction: RecoveryQueueItem }): ReactElement { + const router = useRouter() + const { isMalicious } = transaction + + const url = useMemo( + () => ({ + pathname: AppRoutes.transactions.queue, + query: router.query, + }), + [router.query], + ) + + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/components/dashboard/PendingTxs/PendingTxList.test.ts b/src/components/dashboard/PendingTxs/PendingTxList.test.ts new file mode 100644 index 0000000000..629a55106a --- /dev/null +++ b/src/components/dashboard/PendingTxs/PendingTxList.test.ts @@ -0,0 +1,71 @@ +import { BigNumber } from 'ethers' +import { faker } from '@faker-js/faker' +import { DetailedExecutionInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import type { MultisigExecutionInfo, Transaction } from '@safe-global/safe-gateway-typescript-sdk' + +import { safeInfoBuilder } from '@/tests/builders/safe' +import { _getTransactionsToDisplay } from './PendingTxsList' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +describe('_getTransactionsToDisplay', () => { + it('should return the recovery queue if it has more than or equal to MAX_TXS items', () => { + const walletAddress = faker.finance.ethereumAddress() + const safe = safeInfoBuilder().build() + const recoveryQueue = [ + { timestamp: BigNumber.from(1) }, + { timestamp: BigNumber.from(2) }, + { timestamp: BigNumber.from(3) }, + { timestamp: BigNumber.from(4) }, + { timestamp: BigNumber.from(5) }, + ] as Array + const queue = [] as Array + + const result = _getTransactionsToDisplay({ recoveryQueue, queue, walletAddress, safe }) + expect(result).toStrictEqual(recoveryQueue.slice(0, 4)) + }) + + it('should return the recovery queue followed by the actionable transactions from the queue', () => { + const walletAddress = faker.finance.ethereumAddress() + const safe = safeInfoBuilder().build() + const recoveryQueue = [ + { timestamp: BigNumber.from(1) }, + { timestamp: BigNumber.from(2) }, + { timestamp: BigNumber.from(3) }, + ] as Array + const actionableQueue = [ + { + transaction: { id: '1' }, + executionInfo: { + type: DetailedExecutionInfoType.MULTISIG, + missingSigners: [walletAddress], + } as unknown as MultisigExecutionInfo, + } as unknown as Transaction, + { + transaction: { id: '2' }, + executionInfo: { + type: DetailedExecutionInfoType.MULTISIG, + missingSigners: [walletAddress], + } as unknown as MultisigExecutionInfo, + } as unknown as Transaction, + ] + + const expected = [...recoveryQueue, actionableQueue[0]] + const result = _getTransactionsToDisplay({ recoveryQueue, queue: actionableQueue, walletAddress, safe }) + expect(result).toEqual(expected) + }) + + it('should return the recovery queue followed by the transactions from the queue if there are no actionable transactions', () => { + const walletAddress = faker.finance.ethereumAddress() + const safe = safeInfoBuilder().build() + const recoveryQueue = [ + { timestamp: BigNumber.from(1) }, + { timestamp: BigNumber.from(2) }, + { timestamp: BigNumber.from(3) }, + ] as Array + const queue = [{ transaction: { id: '1' } }, { transaction: { id: '2' } }] as Array + + const expected = [...recoveryQueue, queue[0]] + const result = _getTransactionsToDisplay({ recoveryQueue, queue, walletAddress, safe }) + expect(result).toEqual(expected) + }) +}) diff --git a/src/components/dashboard/PendingTxs/PendingTxListItem.tsx b/src/components/dashboard/PendingTxs/PendingTxListItem.tsx index 639822fb08..8569974e76 100644 --- a/src/components/dashboard/PendingTxs/PendingTxListItem.tsx +++ b/src/components/dashboard/PendingTxs/PendingTxListItem.tsx @@ -42,34 +42,38 @@ const PendingTx = ({ transaction }: PendingTxType): ReactElement => { return ( - {isMultisigExecutionInfo(transaction.executionInfo) && transaction.executionInfo.nonce} + + {isMultisigExecutionInfo(transaction.executionInfo) && transaction.executionInfo.nonce} + - + - + - {isMultisigExecutionInfo(transaction.executionInfo) ? ( - - - - {`${transaction.executionInfo.confirmationsSubmitted}/${transaction.executionInfo.confirmationsRequired}`} - - - ) : ( - - )} + + {isMultisigExecutionInfo(transaction.executionInfo) && ( + + + + {`${transaction.executionInfo.confirmationsSubmitted}/${transaction.executionInfo.confirmationsRequired}`} + + + )} + - {canExecute ? ( - - ) : canSign ? ( - - ) : ( - - )} + + {canExecute ? ( + + ) : canSign ? ( + + ) : ( + + )} + ) diff --git a/src/components/dashboard/PendingTxs/PendingTxsList.tsx b/src/components/dashboard/PendingTxs/PendingTxsList.tsx index 263b5a726b..7ffc1d5f97 100644 --- a/src/components/dashboard/PendingTxs/PendingTxsList.tsx +++ b/src/components/dashboard/PendingTxs/PendingTxsList.tsx @@ -12,6 +12,10 @@ import css from './styles.module.css' import { isSignableBy, isExecutable } from '@/utils/transaction-guards' import useWallet from '@/hooks/wallets/useWallet' import useSafeInfo from '@/hooks/useSafeInfo' +import { useRecoveryQueue } from '@/hooks/useRecoveryQueue' +import { PendingRecoveryListItem } from './PendingRecoveryListItem' +import type { SafeInfo, Transaction } from '@safe-global/safe-gateway-typescript-sdk' +import type { RecoveryQueueItem } from '@/store/recoverySlice' const MAX_TXS = 4 @@ -37,23 +41,58 @@ const LoadingState = () => ( ) +function getActionableTransactions(txs: Transaction[], safe: SafeInfo, walletAddress?: string): Transaction[] { + if (!walletAddress) { + return txs + } + + return txs.filter((tx) => { + return isSignableBy(tx.transaction, walletAddress) || isExecutable(tx.transaction, walletAddress, safe) + }) +} + +export function _getTransactionsToDisplay({ + recoveryQueue, + queue, + walletAddress, + safe, +}: { + recoveryQueue: RecoveryQueueItem[] + queue: Transaction[] + walletAddress?: string + safe: SafeInfo +}): (Transaction | RecoveryQueueItem)[] { + if (recoveryQueue.length >= MAX_TXS) { + return recoveryQueue.slice(0, MAX_TXS) + } + + const actionableQueue = getActionableTransactions(queue, safe, walletAddress) + const _queue = actionableQueue.length > 0 ? actionableQueue : queue + const queueToDisplay = _queue.slice(0, MAX_TXS - recoveryQueue.length) + + return [...recoveryQueue, ...queueToDisplay] +} + +function isRecoveryQueueItem(tx: Transaction | RecoveryQueueItem): tx is RecoveryQueueItem { + return 'args' in tx +} + const PendingTxsList = (): ReactElement | null => { const router = useRouter() const { page, loading } = useTxQueue() const { safe } = useSafeInfo() const wallet = useWallet() const queuedTxns = useMemo(() => getLatestTransactions(page?.results), [page?.results]) + const recoveryQueue = useRecoveryQueue() - const actionableTxs = useMemo(() => { - return wallet - ? queuedTxns.filter( - (tx) => isSignableBy(tx.transaction, wallet.address) || isExecutable(tx.transaction, wallet.address, safe), - ) - : queuedTxns - }, [wallet, queuedTxns, safe]) - - const txs = actionableTxs.length ? actionableTxs : queuedTxns - const txsToDisplay = txs.slice(0, MAX_TXS) + const txsToDisplay = useMemo(() => { + return _getTransactionsToDisplay({ + recoveryQueue, + queue: queuedTxns, + walletAddress: wallet?.address, + safe, + }) + }, [recoveryQueue, queuedTxns, wallet?.address, safe]) const queueUrl = useMemo( () => ({ @@ -76,11 +115,14 @@ const PendingTxsList = (): ReactElement | null => { {loading ? ( - ) : queuedTxns.length ? ( + ) : txsToDisplay.length > 0 ? (
- {txsToDisplay.map((tx) => ( - - ))} + {txsToDisplay.map((tx) => { + if (isRecoveryQueueItem(tx)) { + return + } + return + })}
) : ( diff --git a/src/components/dashboard/PendingTxs/styles.module.css b/src/components/dashboard/PendingTxs/styles.module.css index ad2a6fe17e..8b0091619f 100644 --- a/src/components/dashboard/PendingTxs/styles.module.css +++ b/src/components/dashboard/PendingTxs/styles.module.css @@ -1,11 +1,13 @@ .container { width: 100%; + min-height: 50px; padding: 8px 16px; background-color: var(--color-background-paper); border: 1px solid var(--color-border-light); border-radius: 8px; - flex-wrap: wrap; - display: flex; + display: grid; + grid-template-columns: minmax(30px, min-content) 0.5fr 1fr min-content min-content; + grid-template-areas: 'nonce type info confirmations action'; align-items: center; gap: var(--space-2); } @@ -44,12 +46,3 @@ color: var(--color-static-main); text-align: center; } - -@media (max-width: 599.95px) { - .txInfo { - width: 100%; - order: 1; - flex: auto; - margin-top: calc(var(--space-1) * -1); - } -} diff --git a/src/components/recovery/RecoveryInfo/index.tsx b/src/components/recovery/RecoveryInfo/index.tsx index 5f2fa6f281..f657cb5b91 100644 --- a/src/components/recovery/RecoveryInfo/index.tsx +++ b/src/components/recovery/RecoveryInfo/index.tsx @@ -3,7 +3,11 @@ import type { ReactElement } from 'react' import WarningIcon from '@/public/images/notifications/warning.svg' -export const RecoveryInfo = (): ReactElement => { +export const RecoveryInfo = ({ isMalicious }: { isMalicious: boolean }): ReactElement | null => { + if (!isMalicious) { + return null + } + return ( diff --git a/src/components/recovery/RecoverySummary/index.tsx b/src/components/recovery/RecoverySummary/index.tsx index 4003b3b6a1..0f3acd6cbb 100644 --- a/src/components/recovery/RecoverySummary/index.tsx +++ b/src/components/recovery/RecoverySummary/index.tsx @@ -22,11 +22,9 @@ export function RecoverySummary({ item }: { item: RecoveryQueueItem }): ReactEle
- {isMalicious && ( - - - - )} + + + {wallet && ( diff --git a/src/components/tx-flow/common/OwnerList/index.tsx b/src/components/tx-flow/common/OwnerList/index.tsx index f177b62e67..a72c879713 100644 --- a/src/components/tx-flow/common/OwnerList/index.tsx +++ b/src/components/tx-flow/common/OwnerList/index.tsx @@ -21,7 +21,7 @@ export function OwnerList({ - {title ?? `New owner{owners.length > 1 ? 's' : ''}`} + {title ?? `New owner${owners.length > 1 ? 's' : ''}`} {owners.map((newOwner) => (