Skip to content

Commit

Permalink
feat: pending recoveries in dashboard widget
Browse files Browse the repository at this point in the history
  • Loading branch information
iamacook committed Nov 22, 2023
1 parent 01a20a0 commit be53934
Show file tree
Hide file tree
Showing 8 changed files with 215 additions and 52 deletions.
51 changes: 51 additions & 0 deletions src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link href={url} passHref>
<Box className={css.container}>
<Box gridArea="nonce" />

<Box gridArea="type">
<RecoveryType isMalicious={isMalicious} />
</Box>

<Box gridArea="info">
<RecoveryInfo isMalicious={isMalicious} />
</Box>

<Box gridArea="confirmations">
<RecoveryStatus recovery={transaction} />
</Box>

<Box gridArea="action">
<ChevronRight color="border" />
</Box>
</Box>
</Link>
)
}
71 changes: 71 additions & 0 deletions src/components/dashboard/PendingTxs/PendingTxList.test.ts
Original file line number Diff line number Diff line change
@@ -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<RecoveryQueueItem>
const queue = [] as Array<Transaction>

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<RecoveryQueueItem>
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<RecoveryQueueItem>
const queue = [{ transaction: { id: '1' } }, { transaction: { id: '2' } }] as Array<Transaction>

const expected = [...recoveryQueue, queue[0]]
const result = _getTransactionsToDisplay({ recoveryQueue, queue, walletAddress, safe })
expect(result).toEqual(expected)
})
})
44 changes: 24 additions & 20 deletions src/components/dashboard/PendingTxs/PendingTxListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,34 +42,38 @@ const PendingTx = ({ transaction }: PendingTxType): ReactElement => {
return (
<NextLink href={url} passHref>
<Box className={css.container}>
{isMultisigExecutionInfo(transaction.executionInfo) && transaction.executionInfo.nonce}
<Box gridAra="nonce">
{isMultisigExecutionInfo(transaction.executionInfo) && transaction.executionInfo.nonce}
</Box>

<Box flex={1}>
<Box gridArea="type">
<TxType tx={transaction} short={true} />
</Box>

<Box flex={1} className={css.txInfo}>
<Box gridArea="info">
<TxInfo info={transaction.txInfo} />
</Box>

{isMultisigExecutionInfo(transaction.executionInfo) ? (
<Box className={css.confirmationsCount}>
<SvgIcon component={OwnersIcon} inheritViewBox fontSize="small" />
<Typography variant="caption" fontWeight="bold">
{`${transaction.executionInfo.confirmationsSubmitted}/${transaction.executionInfo.confirmationsRequired}`}
</Typography>
</Box>
) : (
<Box flexGrow={1} />
)}
<Box gridArea="confirmations">
{isMultisigExecutionInfo(transaction.executionInfo) && (
<Box className={css.confirmationsCount}>
<SvgIcon component={OwnersIcon} inheritViewBox fontSize="small" />
<Typography variant="caption" fontWeight="bold">
{`${transaction.executionInfo.confirmationsSubmitted}/${transaction.executionInfo.confirmationsRequired}`}
</Typography>
</Box>
)}
</Box>

{canExecute ? (
<ExecuteTxButton txSummary={transaction} compact />
) : canSign ? (
<SignTxButton txSummary={transaction} compact />
) : (
<ChevronRight color="border" />
)}
<Box gridArea="action">
{canExecute ? (
<ExecuteTxButton txSummary={transaction} compact />
) : canSign ? (
<SignTxButton txSummary={transaction} compact />
) : (
<ChevronRight color="border" />
)}
</Box>
</Box>
</NextLink>
)
Expand Down
70 changes: 56 additions & 14 deletions src/components/dashboard/PendingTxs/PendingTxsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -37,23 +41,58 @@ const LoadingState = () => (
</div>
)

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(
() => ({
Expand All @@ -76,11 +115,14 @@ const PendingTxsList = (): ReactElement | null => {
<WidgetBody>
{loading ? (
<LoadingState />
) : queuedTxns.length ? (
) : txsToDisplay.length > 0 ? (
<div className={css.list}>
{txsToDisplay.map((tx) => (
<PendingTxListItem transaction={tx.transaction} key={tx.transaction.id} />
))}
{txsToDisplay.map((tx) => {
if (isRecoveryQueueItem(tx)) {
return <PendingRecoveryListItem transaction={tx} key={tx.transactionHash} />
}
return <PendingTxListItem transaction={tx.transaction} key={tx.transaction.id} />
})}
</div>
) : (
<EmptyState />
Expand Down
15 changes: 4 additions & 11 deletions src/components/dashboard/PendingTxs/styles.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down Expand Up @@ -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);
}
}
6 changes: 5 additions & 1 deletion src/components/recovery/RecoveryInfo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Tooltip title="Suspicious activity" placement="top" arrow>
<span>
Expand Down
8 changes: 3 additions & 5 deletions src/components/recovery/RecoverySummary/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,9 @@ export function RecoverySummary({ item }: { item: RecoveryQueueItem }): ReactEle
<RecoveryType isMalicious={isMalicious} />
</Box>

{isMalicious && (
<Box gridArea="info" className={txSummaryCss.columnWrap}>
<RecoveryInfo />
</Box>
)}
<Box gridArea="info" className={txSummaryCss.columnWrap}>
<RecoveryInfo isMalicious={isMalicious} />
</Box>

{wallet && (
<Box gridArea="actions" display="flex" justifyContent={{ sm: 'center' }} gap={1}>
Expand Down
2 changes: 1 addition & 1 deletion src/components/tx-flow/common/OwnerList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function OwnerList({
<Paper className={css.container} sx={sx}>
<Typography color="text.secondary" display="flex" alignItems="center">
<SvgIcon component={PlusIcon} inheritViewBox fontSize="small" sx={{ mr: 1 }} />
{title ?? `New owner{owners.length > 1 ? 's' : ''}`}
{title ?? `New owner${owners.length > 1 ? 's' : ''}`}
</Typography>
{owners.map((newOwner) => (
<EthHashInfo
Expand Down

0 comments on commit be53934

Please sign in to comment.