Skip to content

Commit

Permalink
Feat: visually group transactions executed in bulk in the transaction…
Browse files Browse the repository at this point in the history
… history (#3772)

* Group transactions in the history by transaction hash

* remove commented code

* create component for bulk transaction groups

* layout for bulk group

* fix: misalignment of txs on smaller screens

* align grouped columns with non grouped columns

* fix: column widths on mobile

* Add unit test, wrap grouping functions

* fix: lint errors

* fix: untrusted txs warning placement

* Use tx hash returned from backend

* lint errors

* remove unused function

* Change layout and change explorer link

* add unit test for grouping function

* lodash import

* fix typo

* remove orange border from tx group

* fix: transaction count color

* change bulk group title
  • Loading branch information
jmealy authored Jun 20, 2024
1 parent cd1b305 commit 2ecbdb8
Show file tree
Hide file tree
Showing 17 changed files with 310 additions and 46 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"@safe-global/protocol-kit": "^3.1.1",
"@safe-global/safe-apps-sdk": "^9.1.0",
"@safe-global/safe-deployments": "^1.36.0",
"@safe-global/safe-gateway-typescript-sdk": "3.21.1",
"@safe-global/safe-gateway-typescript-sdk": "3.21.2",
"@safe-global/safe-modules-deployments": "^1.2.0",
"@sentry/react": "^7.91.0",
"@spindl-xyz/attribution-lite": "^1.4.0",
Expand Down
1 change: 1 addition & 0 deletions src/components/batch/BatchSidebar/BatchTxItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const BatchTxItem = ({ id, count, timestamp, txDetails, onDelete }: BatchTxItemP
txInfo: txDetails.txInfo,
txStatus: txDetails.txStatus,
safeAppInfo: txDetails.safeAppInfo,
txHash: txDetails.txHash || null,
}),
[timestamp, txDetails],
)
Expand Down
42 changes: 32 additions & 10 deletions src/components/common/ExplorerButton/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { ReactElement, ComponentType, SyntheticEvent } from 'react'
import { IconButton, SvgIcon, Tooltip } from '@mui/material'
import { Box, IconButton, SvgIcon, Tooltip, Typography } from '@mui/material'
import LinkIcon from '@/public/images/common/link.svg'
import Link from 'next/link'

export type ExplorerButtonProps = {
title?: string
href?: string
className?: string
icon?: ComponentType
onClick?: (e: SyntheticEvent) => void
isCompact?: boolean
}

const ExplorerButton = ({
Expand All @@ -16,21 +18,41 @@ const ExplorerButton = ({
icon = LinkIcon,
className,
onClick,
}: ExplorerButtonProps): ReactElement => (
<Tooltip title={title} placement="top">
<IconButton
isCompact = true,
}: ExplorerButtonProps): ReactElement => {
return isCompact ? (
<Tooltip title={title} placement="top">
<IconButton
data-testid="explorer-btn"
className={className}
target="_blank"
rel="noreferrer"
href={href}
size="small"
sx={{ color: 'inherit' }}
onClick={onClick}
>
<SvgIcon component={icon} inheritViewBox fontSize="small" />
</IconButton>
</Tooltip>
) : (
<Link
data-testid="explorer-btn"
className={className}
target="_blank"
rel="noreferrer"
href={href}
size="small"
sx={{ color: 'inherit' }}
onClick={onClick}
>
<SvgIcon component={icon} inheritViewBox fontSize="small" />
</IconButton>
</Tooltip>
)
<Box display="flex" alignItems="center">
<Typography fontWeight={700} fontSize="small" mr="var(--space-1)" noWrap>
View on explorer
</Typography>

<SvgIcon component={icon} inheritViewBox fontSize="small" />
</Box>
</Link>
)
}

export default ExplorerButton
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ describe('AppFrame', () => {
},
],
},
txHash: null,
},
conflictType: ConflictType.NONE,
},
Expand Down
53 changes: 53 additions & 0 deletions src/components/transactions/BulkTxListGroup/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ReactElement } from 'react'
import { Box, Paper, SvgIcon, Typography } from '@mui/material'
import type { Transaction } from '@safe-global/safe-gateway-typescript-sdk'
import { isMultisigExecutionInfo } from '@/utils/transaction-guards'
import ExpandableTransactionItem from '@/components/transactions/TxListItem/ExpandableTransactionItem'
import BatchIcon from '@/public/images/common/batch.svg'
import css from './styles.module.css'
import ExplorerButton from '@/components/common/ExplorerButton'
import { getBlockExplorerLink } from '@/utils/chains'
import { useCurrentChain } from '@/hooks/useChains'

const GroupedTxListItems = ({
groupedListItems,
transactionHash,
}: {
groupedListItems: Transaction[]
transactionHash: string
}): ReactElement | null => {
const chain = useCurrentChain()
const explorerLink = chain && getBlockExplorerLink(chain, transactionHash)?.href
if (groupedListItems.length === 0) return null

return (
<Paper className={css.container}>
<Box gridArea="icon">
<SvgIcon className={css.icon} component={BatchIcon} inheritViewBox fontSize="medium" />
</Box>
<Box gridArea="info">
<Typography noWrap>Bulk transactions</Typography>
</Box>
<Box className={css.action}>{groupedListItems.length} transactions</Box>
<Box className={css.hash}>
<ExplorerButton href={explorerLink} isCompact={false} />
</Box>

<Box gridArea="items" className={css.txItems}>
{groupedListItems.map((tx) => {
const nonce = isMultisigExecutionInfo(tx.transaction.executionInfo) ? tx.transaction.executionInfo.nonce : ''
return (
<Box position="relative" key={tx.transaction.id}>
<Box className={css.nonce}>
<Typography className={css.nonce}>{nonce}</Typography>
</Box>
<ExpandableTransactionItem item={tx} isBulkGroup={true} />
</Box>
)
})}
</Box>
</Paper>
)
}

export default GroupedTxListItems
60 changes: 60 additions & 0 deletions src/components/transactions/BulkTxListGroup/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
.container {
position: relative;
padding: var(--space-2);
display: grid;
align-items: center;
grid-template-columns: minmax(50px, 0.25fr) minmax(240px, 2fr) minmax(150px, 4fr) minmax(170px, 1fr);
grid-template-areas:
'icon info action hash'
'nonce items items items';
}

.action {
margin-left: var(--space-2);
grid-area: action;
color: var(--color-text-secondary);
}

.hash {
grid-area: hash;
display: grid;
justify-content: flex-end;
}

.nonce {
position: absolute;
left: -24px;
top: var(--space-1);
}

.txItems {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-top: var(--space-2);
}

.txItems :global(.MuiAccordion-root) {
border-color: var(--color-border-light);
}

@media (max-width: 699px) {
.container {
grid-template-columns: minmax(30px, 0.25fr) minmax(230px, 3fr);
grid-template-areas:
'icon info '
'nonce action'
'nonce hash '
'nonce items';
}

.action {
margin: 0;
}
.hash {
justify-content: flex-start;
}
.nonce {
left: -16px;
}
}
2 changes: 1 addition & 1 deletion src/components/transactions/GroupedTxListItems/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const TxGroup = ({ groupedListItems }: { groupedListItems: Transaction[] }): Rea
key={tx.transaction.id}
className={replacedTxIds.includes(tx.transaction.id) ? css.willBeReplaced : undefined}
>
<ExpandableTransactionItem item={tx} isGrouped />
<ExpandableTransactionItem item={tx} isConflictGroup />
</div>
))}
</Box>
Expand Down
26 changes: 19 additions & 7 deletions src/components/transactions/TxList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,41 @@
import GroupedTxListItems from '@/components/transactions/GroupedTxListItems'
import { groupConflictingTxs } from '@/utils/tx-list'
import { groupTxs } from '@/utils/tx-list'
import { Box } from '@mui/material'
import type { TransactionListPage } from '@safe-global/safe-gateway-typescript-sdk'
import type { Transaction, TransactionListPage } from '@safe-global/safe-gateway-typescript-sdk'
import type { ReactElement, ReactNode } from 'react'
import { useMemo } from 'react'
import TxListItem from '../TxListItem'
import css from './styles.module.css'
import uniq from 'lodash/uniq'
import BulkTxListGroup from '@/components/transactions/BulkTxListGroup'

type TxListProps = {
items: TransactionListPage['results']
}

const getBulkGroupTxHash = (group: Transaction[]) => {
const hashList = group.map((item) => item.transaction.txHash)
return uniq(hashList).length === 1 ? hashList[0] : undefined
}

export const TxListGrid = ({ children }: { children: ReactNode }): ReactElement => {
return <Box className={css.container}>{children}</Box>
}

const TxList = ({ items }: TxListProps): ReactElement => {
const groupedItems = useMemo(() => groupConflictingTxs(items), [items])
const groupedTransactions = useMemo(() => groupTxs(items), [items])

const transactions = groupedTransactions.map((item, index) => {
if (!Array.isArray(item)) {
return <TxListItem key={index} item={item} />
}

const transactions = groupedItems.map((item, index) => {
if (Array.isArray(item)) {
return <GroupedTxListItems key={index} groupedListItems={item} />
const bulkTransactionHash = getBulkGroupTxHash(item)
if (bulkTransactionHash) {
return <BulkTxListGroup key={index} groupedListItems={item} transactionHash={bulkTransactionHash} />
}

return <TxListItem key={index} item={item} />
return <GroupedTxListItems key={index} groupedListItems={item} />
})

return <TxListGrid>{transactions}</TxListGrid>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import classNames from 'classnames'
import { trackEvent, TX_LIST_EVENTS } from '@/services/analytics'

type ExpandableTransactionItemProps = {
isGrouped?: boolean
isConflictGroup?: boolean
isBulkGroup?: boolean
item: Transaction
txDetails?: TransactionDetails
}

export const ExpandableTransactionItem = ({
isGrouped = false,
isConflictGroup = false,
isBulkGroup = false,
item,
txDetails,
testId,
Expand Down Expand Up @@ -56,7 +58,7 @@ export const ExpandableTransactionItem = ({
},
}}
>
<TxSummary item={item} isGrouped={isGrouped} />
<TxSummary item={item} isConflictGroup={isConflictGroup} isBulkGroup={isBulkGroup} />
</AccordionSummary>

<AccordionDetails data-testid="accordion-details" sx={{ padding: 0 }}>
Expand Down
28 changes: 18 additions & 10 deletions src/components/transactions/TxSummary/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,64 +54,72 @@ const mockTransactionInHistory = {

describe('TxSummary', () => {
it('should display a nonce if transaction is not grouped', () => {
const { getByText } = render(<TxSummary item={mockTransaction} isGrouped={false} />)
const { getByText } = render(<TxSummary item={mockTransaction} isConflictGroup={false} />)

expect(getByText('7')).toBeInTheDocument()
})

it('should not display a nonce if transaction is grouped', () => {
const { queryByText } = render(<TxSummary item={mockTransaction} isGrouped={true} />)
const { queryByText } = render(<TxSummary item={mockTransaction} isConflictGroup={true} />)

expect(queryByText('7')).not.toBeInTheDocument()
})

it('should not display a nonce if there is no executionInfo', () => {
const { queryByText } = render(<TxSummary item={mockTransactionWithoutExecutionInfo} isGrouped={true} />)
const { queryByText } = render(<TxSummary item={mockTransactionWithoutExecutionInfo} isConflictGroup={true} />)

expect(queryByText('7')).not.toBeInTheDocument()
})

it('should not display a nonce for items in bulk execution group', () => {
const { queryByText } = render(
<TxSummary item={mockTransactionWithoutExecutionInfo} isBulkGroup={true} isConflictGroup={false} />,
)

expect(queryByText('7')).not.toBeInTheDocument()
})

it('should display confirmations if transactions is in queue', () => {
const { getByText } = render(<TxSummary item={mockTransaction} isGrouped={false} />)
const { getByText } = render(<TxSummary item={mockTransaction} isConflictGroup={false} />)

expect(getByText('1 out of 3')).toBeInTheDocument()
})

it('should not display confirmations if transactions is already executed', () => {
const { queryByText } = render(<TxSummary item={mockTransactionInHistory} isGrouped={false} />)
const { queryByText } = render(<TxSummary item={mockTransactionInHistory} isConflictGroup={false} />)

expect(queryByText('1 out of 3')).not.toBeInTheDocument()
})

it('should not display confirmations if there is no executionInfo', () => {
const { queryByText } = render(<TxSummary item={mockTransactionWithoutExecutionInfo} isGrouped={false} />)
const { queryByText } = render(<TxSummary item={mockTransactionWithoutExecutionInfo} isConflictGroup={false} />)

expect(queryByText('1 out of 3')).not.toBeInTheDocument()
})

it('should display a Sign button if confirmations are missing', () => {
const { getByText } = render(<TxSummary item={mockTransaction} isGrouped={false} />)
const { getByText } = render(<TxSummary item={mockTransaction} isConflictGroup={false} />)

expect(getByText('Confirm')).toBeInTheDocument()
})

it('should display a status label if transaction is in queue and pending', () => {
jest.spyOn(pending, 'default').mockReturnValue(true)
const { getByTestId } = render(<TxSummary item={mockTransaction} isGrouped={false} />)
const { getByTestId } = render(<TxSummary item={mockTransaction} isConflictGroup={false} />)

expect(getByTestId('tx-status-label')).toBeInTheDocument()
})

it('should display a status label if transaction is not in queue', () => {
jest.spyOn(pending, 'default').mockReturnValue(true)
const { getByTestId } = render(<TxSummary item={mockTransactionInHistory} isGrouped={false} />)
const { getByTestId } = render(<TxSummary item={mockTransactionInHistory} isConflictGroup={false} />)

expect(getByTestId('tx-status-label')).toBeInTheDocument()
})

it('should not display a status label if transaction is in queue and not pending', () => {
jest.spyOn(pending, 'default').mockReturnValue(false)
const { queryByTestId } = render(<TxSummary item={mockTransaction} isGrouped={false} />)
const { queryByTestId } = render(<TxSummary item={mockTransaction} isConflictGroup={false} />)

expect(queryByTestId('tx-status-label')).not.toBeInTheDocument()
})
Expand Down
Loading

0 comments on commit 2ecbdb8

Please sign in to comment.