diff --git a/src/components/tx-flow/common/TxNonce/index.tsx b/src/components/tx-flow/common/TxNonce/index.tsx index 1a28af4e5d..53e2118271 100644 --- a/src/components/tx-flow/common/TxNonce/index.tsx +++ b/src/components/tx-flow/common/TxNonce/index.tsx @@ -1,29 +1,132 @@ -import { type ChangeEvent, useCallback, useContext } from 'react' -import { Skeleton, Typography } from '@mui/material' +import { memo, type ReactElement, type SyntheticEvent, useCallback, useContext, useMemo, useRef, useState } from 'react' + +import { + Autocomplete, + Box, + IconButton, + InputAdornment, + Skeleton, + Tooltip, + Popper, + type PopperProps, + type AutocompleteValue, + type MenuItemProps, + MenuItem, +} from '@mui/material' import { SafeTxContext } from '../../SafeTxProvider' +import RotateLeftIcon from '@mui/icons-material/RotateLeft' +import NumberField from '@/components/common/NumberField' +import { useQueuedTxByNonce } from '@/hooks/useTxQueue' +import useSafeInfo from '@/hooks/useSafeInfo' import css from './styles.module.css' +import useAddressBook from '@/hooks/useAddressBook' +import { getLatestTransactions } from '@/utils/tx-list' +import { getTransactionType } from '@/hooks/useTransactionType' +import usePreviousNonces from '@/hooks/usePreviousNonces' + +const CustomPopper = function (props: PopperProps) { + return +} + +const NonceFormOption = memo(function NonceFormOption({ + nonce, + menuItemProps, +}: { + nonce: number + menuItemProps: MenuItemProps +}): ReactElement { + const addressBook = useAddressBook() + const transactions = useQueuedTxByNonce(nonce) + + const label = useMemo(() => { + const [{ transaction }] = getLatestTransactions(transactions) + return getTransactionType(transaction, addressBook).text + }, [addressBook, transactions]) + + return ( + + {nonce} ({label} transaction) + + ) +}) const TxNonce = () => { - const { nonce, setNonce, safeTx } = useContext(SafeTxContext) + const [error, setError] = useState(false) + const { safe } = useSafeInfo() + const previousNonces = usePreviousNonces() + const { nonce, setNonce, safeTx, recommendedNonce } = useContext(SafeTxContext) + const isEmpty = useRef(false) const isEditable = !safeTx || safeTx?.signatures.size === 0 + const readonly = !isEditable + + const isValidInput = useCallback( + (value: string | AutocompleteValue) => { + return Number(value) >= safe.nonce + }, + [safe.nonce], + ) - const onChange = useCallback( - (e: ChangeEvent) => { - const newNonce = Number(e.target.textContent) - setNonce(newNonce) + const handleChange = useCallback( + (e: SyntheticEvent, value: string | AutocompleteValue) => { + isEmpty.current = value === '' + const nonce = Number(value) + if (isNaN(nonce)) return + setError(!isValidInput(value)) + setNonce(nonce) }, - [setNonce], + [isValidInput, setNonce], ) + const resetNonce = useCallback(() => { + setError(false) + isEmpty.current = false + setNonce(recommendedNonce) + }, [recommendedNonce, setNonce]) + if (nonce === undefined) return return ( - - # - - {nonce} - - + + Nonce + option.toString()} + renderOption={(props, option: number) => } + disableClearable + componentsProps={{ + paper: { + elevation: 2, + }, + }} + renderInput={(params) => ( + + + + + + + + ), + readOnly: readonly, + }} + className={css.input} + /> + )} + PopperComponent={CustomPopper} + /> + ) } diff --git a/src/components/tx-flow/common/TxNonce/styles.module.css b/src/components/tx-flow/common/TxNonce/styles.module.css index 0ee677aad7..63cbd48d7d 100644 --- a/src/components/tx-flow/common/TxNonce/styles.module.css +++ b/src/components/tx-flow/common/TxNonce/styles.module.css @@ -1,14 +1,8 @@ -.input { - white-space: nowrap; - width: 200px; - overflow: hidden; +.input :global .MuiOutlinedInput-root { + padding: 0; } -.input br { - display: none; -} - -.input * { - display: inline; - white-space: nowrap; +.adornment { + margin-left: 0; + margin-right: 4px; } diff --git a/src/components/tx/NonceForm/index.tsx b/src/components/tx/NonceForm/index.tsx deleted file mode 100644 index 67421f21c8..0000000000 --- a/src/components/tx/NonceForm/index.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { memo, useMemo } from 'react' -import type { ReactElement } from 'react' -import { useController, useFormContext, useWatch } from 'react-hook-form' -import { Autocomplete, IconButton, InputAdornment, MenuItem, Tooltip } from '@mui/material' -import RotateLeftIcon from '@mui/icons-material/RotateLeft' -import useSafeInfo from '@/hooks/useSafeInfo' -import NumberField from '@/components/common/NumberField' -import useTxQueue, { useQueuedTxByNonce } from '@/hooks/useTxQueue' -import { isMultisigExecutionInfo, isTransactionListItem } from '@/utils/transaction-guards' -import { uniqBy } from 'lodash' -import { getTransactionType } from '@/hooks/useTransactionType' -import useAddressBook from '@/hooks/useAddressBook' -import { getLatestTransactions } from '@/utils/tx-list' -import type { MenuItemProps } from '@mui/material' - -type NonceFormProps = { - name: string - nonce: number - recommendedNonce?: number - readonly?: boolean -} - -const NonceFormOption = memo(function NonceFormOption({ - nonce, - menuItemProps, -}: { - nonce: number - menuItemProps: MenuItemProps -}): ReactElement { - const addressBook = useAddressBook() - const transactions = useQueuedTxByNonce(nonce) - - const label = useMemo(() => { - const [{ transaction }] = getLatestTransactions(transactions) - return getTransactionType(transaction, addressBook).text - }, [addressBook, transactions]) - - return ( - - {nonce} ({label} transaction) - - ) -}) - -const NonceForm = ({ name, nonce, recommendedNonce, readonly }: NonceFormProps): ReactElement => { - const { safe } = useSafeInfo() - const safeNonce = safe.nonce || 0 - - // Initialise form field - const { setValue, control } = useFormContext() || {} - const { - field: { ref, onBlur, onChange, value }, - fieldState, - } = useController({ - name, - control, - defaultValue: nonce, - rules: { - required: true, - validate: (val: number) => { - if (!Number.isInteger(val)) { - return 'Nonce must be an integer' - } else if (val < safeNonce) { - return `Nonce can't be lower than ${safeNonce}` - } - }, - }, - }) - - // Autocomplete options - const { page } = useTxQueue() - const queuedTxs = useMemo(() => { - if (!page || page.results.length === 0) { - return [] - } - - const txs = page.results.filter(isTransactionListItem).map((item) => item.transaction) - - return uniqBy(txs, (tx) => { - return isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : '' - }) - }, [page]) - - const options = useMemo(() => { - return queuedTxs - .map((tx) => (isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : undefined)) - .filter((nonce) => nonce !== undefined) - }, [queuedTxs]) - - // Warn about a higher nonce - const editableNonce = useWatch({ name, control, exact: true }) - const nonceWarning = - recommendedNonce != null && editableNonce > recommendedNonce ? `Recommended nonce is ${recommendedNonce}` : '' - const label = fieldState.error?.message || nonceWarning || 'Safe Account transaction nonce' - - const onResetNonce = () => { - if (recommendedNonce != null) { - setValue(name, recommendedNonce, { shouldValidate: true }) - } - } - - return ( - { - onChange(value ? Number(value) : '') - }} - options={options} - disabled={nonce == null || readonly} - getOptionLabel={(option) => option.toString()} - renderOption={(props, option: number) => } - disableClearable - componentsProps={{ - paper: { - elevation: 2, - }, - }} - renderInput={(params) => ( - - - - - - - - ), - readOnly: readonly, - }} - InputLabelProps={{ - ...params.InputLabelProps, - shrink: true, - }} - /> - )} - /> - ) -} - -export default NonceForm diff --git a/src/hooks/__tests__/useBatchedTxs.test.ts b/src/hooks/__tests__/useBatchedTxs.test.ts index 5b8d6c33dd..67480d5033 100644 --- a/src/hooks/__tests__/useBatchedTxs.test.ts +++ b/src/hooks/__tests__/useBatchedTxs.test.ts @@ -1,67 +1,7 @@ -import type { - AddressEx, - MultisigExecutionInfo, - Transaction, - TransactionInfo, - TransactionListItem, - TransactionSummary, - TransferInfo, -} from '@safe-global/safe-gateway-typescript-sdk' -import { - ConflictType, - DetailedExecutionInfoType, - TransactionInfoType, - TransactionListItemType, - TransactionStatus, - TransactionTokenType, - TransferDirection, -} from '@safe-global/safe-gateway-typescript-sdk' +import type { MultisigExecutionInfo, Transaction, TransactionListItem } from '@safe-global/safe-gateway-typescript-sdk' +import { ConflictType, TransactionListItemType } from '@safe-global/safe-gateway-typescript-sdk' import { getBatchableTransactions } from '@/hooks/useBatchedTxs' - -const mockAddressEx: AddressEx = { - value: 'string', -} - -const mockTransferInfo: TransferInfo = { - type: TransactionTokenType.ERC20, - tokenAddress: 'string', - value: 'string', -} - -const mockTxInfo: TransactionInfo = { - type: TransactionInfoType.TRANSFER, - sender: mockAddressEx, - recipient: mockAddressEx, - direction: TransferDirection.OUTGOING, - transferInfo: mockTransferInfo, -} - -const defaultTx: TransactionSummary = { - id: '', - timestamp: 0, - txInfo: mockTxInfo, - txStatus: TransactionStatus.AWAITING_CONFIRMATIONS, - executionInfo: { - type: DetailedExecutionInfoType.MULTISIG, - nonce: 1, - confirmationsRequired: 2, - confirmationsSubmitted: 2, - }, -} - -const getMockTx = ({ nonce }: { nonce?: number }): Transaction => { - return { - transaction: { - ...defaultTx, - executionInfo: { - ...defaultTx.executionInfo, - nonce: nonce ?? (defaultTx.executionInfo as MultisigExecutionInfo).nonce, - } as MultisigExecutionInfo, - }, - type: TransactionListItemType.TRANSACTION, - conflictType: ConflictType.NONE, - } -} +import { defaultTx, getMockTx } from '@/tests/mocks/transactions' describe('getBatchableTransactions', () => { it('should return an empty array if no transactions are passed', () => { diff --git a/src/hooks/__tests__/usePreviousNonces.test.ts b/src/hooks/__tests__/usePreviousNonces.test.ts new file mode 100644 index 0000000000..3115a03cb7 --- /dev/null +++ b/src/hooks/__tests__/usePreviousNonces.test.ts @@ -0,0 +1,29 @@ +import { _getUniqueQueuedTxs } from '@/hooks/usePreviousNonces' +import { getMockTx } from '@/tests/mocks/transactions' + +describe('_getUniqueQueuedTxs', () => { + it('returns an empty array if input is undefined', () => { + const result = _getUniqueQueuedTxs() + + expect(result).toEqual([]) + }) + + it('returns an empty array if input is an empty array', () => { + const result = _getUniqueQueuedTxs({ results: [] }) + + expect(result).toEqual([]) + }) + + it('only returns one transaction per nonce', () => { + const mockTx = getMockTx({ nonce: 0 }) + const mockTx1 = getMockTx({ nonce: 1 }) + const mockTx2 = getMockTx({ nonce: 1 }) + + const mockPage = { + results: [mockTx, mockTx1, mockTx2], + } + const result = _getUniqueQueuedTxs(mockPage) + + expect(result.length).toEqual(2) + }) +}) diff --git a/src/hooks/usePreviousNonces.ts b/src/hooks/usePreviousNonces.ts new file mode 100644 index 0000000000..ae7ef9e635 --- /dev/null +++ b/src/hooks/usePreviousNonces.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react' +import { isMultisigExecutionInfo, isTransactionListItem } from '@/utils/transaction-guards' +import { uniqBy } from 'lodash' +import useTxQueue from '@/hooks/useTxQueue' +import { type TransactionListPage } from '@safe-global/safe-gateway-typescript-sdk' + +export const _getUniqueQueuedTxs = (page?: TransactionListPage) => { + if (!page) { + return [] + } + + const txs = page.results.filter(isTransactionListItem).map((item) => item.transaction) + + return uniqBy(txs, (tx) => { + return isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : '' + }) +} + +const usePreviousNonces = () => { + const { page } = useTxQueue() + + const previousNonces = useMemo(() => { + return _getUniqueQueuedTxs(page) + .map((tx) => (isMultisigExecutionInfo(tx.executionInfo) ? tx.executionInfo.nonce : undefined)) + .filter((nonce): nonce is number => !!nonce) + }, [page]) + + return previousNonces +} + +export default usePreviousNonces diff --git a/src/tests/mocks/transactions.ts b/src/tests/mocks/transactions.ts new file mode 100644 index 0000000000..cc61f95847 --- /dev/null +++ b/src/tests/mocks/transactions.ts @@ -0,0 +1,60 @@ +import { + type AddressEx, + ConflictType, + DetailedExecutionInfoType, + type MultisigExecutionInfo, + type Transaction, + type TransactionInfo, + TransactionInfoType, + TransactionListItemType, + TransactionStatus, + type TransactionSummary, + TransactionTokenType, + TransferDirection, + type TransferInfo, +} from '@safe-global/safe-gateway-typescript-sdk' + +const mockAddressEx: AddressEx = { + value: 'string', +} + +const mockTransferInfo: TransferInfo = { + type: TransactionTokenType.ERC20, + tokenAddress: 'string', + value: 'string', +} + +const mockTxInfo: TransactionInfo = { + type: TransactionInfoType.TRANSFER, + sender: mockAddressEx, + recipient: mockAddressEx, + direction: TransferDirection.OUTGOING, + transferInfo: mockTransferInfo, +} + +export const defaultTx: TransactionSummary = { + id: '', + timestamp: 0, + txInfo: mockTxInfo, + txStatus: TransactionStatus.AWAITING_CONFIRMATIONS, + executionInfo: { + type: DetailedExecutionInfoType.MULTISIG, + nonce: 1, + confirmationsRequired: 2, + confirmationsSubmitted: 2, + }, +} + +export const getMockTx = ({ nonce }: { nonce?: number }): Transaction => { + return { + transaction: { + ...defaultTx, + executionInfo: { + ...defaultTx.executionInfo, + nonce: nonce ?? (defaultTx.executionInfo as MultisigExecutionInfo).nonce, + } as MultisigExecutionInfo, + }, + type: TransactionListItemType.TRANSACTION, + conflictType: ConflictType.NONE, + } +}