diff --git a/src/token/components/TokenBalance.test.tsx b/src/token/components/TokenBalance.test.tsx index fed23a5d6f..e04bed1233 100644 --- a/src/token/components/TokenBalance.test.tsx +++ b/src/token/components/TokenBalance.test.tsx @@ -112,7 +112,13 @@ describe('TokenBalance', () => { render(); const actionButton = screen.getByRole('button', { name: 'Use max' }); - fireEvent.keyDown(actionButton); + fireEvent.keyDown(actionButton, { key: 'Escape' }); + expect(onActionPress).not.toHaveBeenCalled(); + + fireEvent.keyDown(actionButton, { key: ' ' }); + expect(onActionPress).toHaveBeenCalled(); + + fireEvent.keyDown(actionButton, { key: 'Enter' }); expect(onActionPress).toHaveBeenCalled(); }); diff --git a/src/token/components/TokenBalance.tsx b/src/token/components/TokenBalance.tsx index 0c93c13cd5..e7d4232b7f 100644 --- a/src/token/components/TokenBalance.tsx +++ b/src/token/components/TokenBalance.tsx @@ -2,33 +2,47 @@ import { formatFiatAmount } from '@/internal/utils/formatFiatAmount'; import { truncateDecimalPlaces } from '@/internal/utils/truncateDecimalPlaces'; import { border, cn, color, text } from '@/styles/theme'; import { TokenImage } from '@/token/components/TokenImage'; -import { useCallback } from 'react'; +import { useMemo } from 'react'; import { formatUnits } from 'viem'; import type { TokenBalanceProps } from '../types'; export function TokenBalance({ token, onClick, + onActionPress, + actionText = 'Use max', classNames, + 'aria-label': ariaLabel, ...contentProps }: TokenBalanceProps) { if (onClick) { return ( - + {onActionPress && ( + )} - data-testid="ockTokenBalanceButton" - > - - + ); } @@ -45,6 +59,13 @@ export function TokenBalance({ {...contentProps} classNames={classNames} /> + {onActionPress && ( + + )} ); } @@ -53,31 +74,26 @@ function TokenBalanceContent({ token, subtitle, showImage = true, - actionText = 'Use max', onActionPress, tokenSize = 40, classNames, }: TokenBalanceProps) { - const formattedFiatValue = formatFiatAmount({ - amount: token.fiatBalance, - currency: 'USD', - }); - - const formattedCryptoValue = truncateDecimalPlaces( - formatUnits(BigInt(token.cryptoBalance), token.decimals), - 3, + const formattedFiatValue = useMemo( + () => + formatFiatAmount({ + amount: token.fiatBalance, + currency: 'USD', + }), + [token.fiatBalance], ); - const handleActionPress = useCallback( - ( - e: - | React.MouseEvent - | React.KeyboardEvent, - ) => { - e.stopPropagation(); - onActionPress?.(); - }, - [onActionPress], + const formattedCryptoValue = useMemo( + () => + truncateDecimalPlaces( + formatUnits(BigInt(token.cryptoBalance), token.decimals), + 3, + ), + [token.cryptoBalance, token.decimals], ); return ( @@ -107,25 +123,7 @@ function TokenBalanceContent({
- {onActionPress ? ( -
- {actionText} -
- ) : ( + {!onActionPress && ( ); } + +function TokenBalanceActionButton({ + actionText, + onActionPress, + className, +}: Pick & { + className?: string; +}) { + return ( + + ); +} diff --git a/src/token/types.ts b/src/token/types.ts index 0a29f5a084..b08fa0ed5e 100644 --- a/src/token/types.ts +++ b/src/token/types.ts @@ -130,12 +130,10 @@ export type TokenBalanceProps = { token: PortfolioTokenWithFiatValue; /** Subtitle to display next to the token name (eg. "available") */ subtitle?: string; - /** Show the token image (default: true) */ - showImage?: boolean; /** Click handler for the whole component*/ onClick?: (token: PortfolioTokenWithFiatValue) => void; - /** Size of the token image in px (default: 40) */ - tokenSize?: number; + /** Optional aria label for the component */ + 'aria-label'?: string; /** Optional additional CSS classes to apply to the component */ classNames?: { container?: string; @@ -146,13 +144,27 @@ export type TokenBalanceProps = { }; } & ( | { - /** Hide the action button (default)*/ - actionText?: never; - onActionPress?: never; + /** Show the token image (default: true) */ + showImage?: true; + /** Size of the token image in px (default: 40) */ + tokenSize?: number; } | { - /** Show an additional action button (eg. "Use max") */ - actionText?: string; - onActionPress: () => void; + /** Hide the token image */ + showImage: false; + /** Size of the token image in px (default: 40) */ + tokenSize?: never; } -); +) & + ( + | { + /** Hide the action button (default)*/ + onActionPress?: never; + actionText?: never; + } + | { + /** Show an additional action button (eg. "Use max") */ + onActionPress: () => void; + actionText?: string; + } + ); diff --git a/src/wallet/components/WalletAdvancedContent.test.tsx b/src/wallet/components/WalletAdvancedContent.test.tsx index 6d5e24a463..fc9c3ce8cf 100644 --- a/src/wallet/components/WalletAdvancedContent.test.tsx +++ b/src/wallet/components/WalletAdvancedContent.test.tsx @@ -42,6 +42,14 @@ vi.mock('./WalletAdvancedSwap', () => ({ ), })); +vi.mock('./wallet-advanced-send/components/Send', () => ({ + Send: ({ className }: { className?: string }) => ( +
+ WalletAdvancedSend +
+ ), +})); + vi.mock('./WalletProvider', () => ({ useWalletContext: vi.fn(), WalletProvider: ({ children }: { children: React.ReactNode }) => ( @@ -267,6 +275,9 @@ describe('WalletAdvancedContent', () => { expect( screen.queryByTestId('ockWalletAdvancedSwap'), ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('ockWalletAdvancedSend'), + ).not.toBeInTheDocument(); }); it('renders WalletAdvancedSwap when activeFeature is swap', () => { @@ -286,6 +297,31 @@ describe('WalletAdvancedContent', () => { expect( screen.queryByTestId('ockWalletAdvancedQrReceive'), ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('ockWalletAdvancedSend'), + ).not.toBeInTheDocument(); + }); + + it('renders WalletAdvancedSend when activeFeature is send', () => { + mockUseWalletAdvancedContext.mockReturnValue({ + ...defaultMockUseWalletAdvancedContext, + activeFeature: 'send', + }); + + render( + +
WalletAdvancedContent
+
, + ); + + expect(screen.getByTestId('ockWalletAdvancedSend')).toBeDefined(); + expect(screen.queryByTestId('ockWalletAdvancedSend')).toBeInTheDocument(); + expect( + screen.queryByTestId('ockWalletAdvancedQrReceive'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('ockWalletAdvancedSwap'), + ).not.toBeInTheDocument(); }); it('correctly maps token balances to the swap component', () => { diff --git a/src/wallet/components/WalletAdvancedContent.tsx b/src/wallet/components/WalletAdvancedContent.tsx index 005f8d02e6..11d39a917e 100644 --- a/src/wallet/components/WalletAdvancedContent.tsx +++ b/src/wallet/components/WalletAdvancedContent.tsx @@ -8,6 +8,7 @@ import { useWalletAdvancedContext } from './WalletAdvancedProvider'; import { WalletAdvancedQrReceive } from './WalletAdvancedQrReceive'; import { WalletAdvancedSwap } from './WalletAdvancedSwap'; import { useWalletContext } from './WalletProvider'; +import { Send } from './wallet-advanced-send/components/Send'; export function WalletAdvancedContent({ children, @@ -38,6 +39,14 @@ export function WalletAdvancedContent({ }, [isSubComponentClosing, setIsSubComponentOpen, setIsSubComponentClosing]); const content = useMemo(() => { + if (activeFeature === 'send') { + return ( + + + + ); + } + if (activeFeature === 'qr') { return ( diff --git a/src/wallet/components/WalletAdvancedTransactionActions.test.tsx b/src/wallet/components/WalletAdvancedTransactionActions.test.tsx index 7c59ea0a2a..896cddb029 100644 --- a/src/wallet/components/WalletAdvancedTransactionActions.test.tsx +++ b/src/wallet/components/WalletAdvancedTransactionActions.test.tsx @@ -142,16 +142,19 @@ describe('WalletAdvancedTransactionActons', () => { expect(window.open).not.toHaveBeenCalled(); }); - it('opens the send page when the send button is clicked', () => { + it('sets activeFeature to send when the send button is clicked', () => { + mockUseWalletAdvancedContext.mockReturnValue( + defaultMockUseWalletAdvancedContext, + ); + render(); const sendButton = screen.getByRole('button', { name: 'Send' }); fireEvent.click(sendButton); - expect(window.open).toHaveBeenCalledWith( - 'https://wallet.coinbase.com', - '_blank', - ); + expect( + defaultMockUseWalletAdvancedContext.setActiveFeature, + ).toHaveBeenCalledWith('send'); }); it('sets activeFeature to swap when the swap button is clicked', () => { diff --git a/src/wallet/components/WalletAdvancedTransactionActions.tsx b/src/wallet/components/WalletAdvancedTransactionActions.tsx index bb89bb3778..f7d2c9ced4 100644 --- a/src/wallet/components/WalletAdvancedTransactionActions.tsx +++ b/src/wallet/components/WalletAdvancedTransactionActions.tsx @@ -80,8 +80,8 @@ export function WalletAdvancedTransactionActions({ const handleSend = useCallback(() => { handleAnalyticsOptionSelected(WalletOption.Send); - window.open('https://wallet.coinbase.com', '_blank'); - }, [handleAnalyticsOptionSelected]); + setActiveFeature('send'); + }, [handleAnalyticsOptionSelected, setActiveFeature]); const handleSwap = useCallback(() => { handleAnalyticsOptionSelected(WalletOption.Swap); diff --git a/src/wallet/components/wallet-advanced-send/components/Send.test.tsx b/src/wallet/components/wallet-advanced-send/components/Send.test.tsx new file mode 100644 index 0000000000..7cf1585f2e --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/Send.test.tsx @@ -0,0 +1,176 @@ +import { Skeleton } from '@/internal/components/Skeleton'; +import { render, screen } from '@testing-library/react'; +import type { Address } from 'viem'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ETH_REQUIRED_FOR_SEND } from '../constants'; +import type { SendContextType } from '../types'; +import { Send } from './Send'; +import { SendAddressSelection } from './SendAddressSelection'; +import { SendAmountInput } from './SendAmountInput'; +import { SendButton } from './SendButton'; +import { SendFundWallet } from './SendFundWallet'; +import { SendHeader } from './SendHeader'; +import { SendProvider, useSendContext } from './SendProvider'; +import { SendTokenSelector } from './SendTokenSelector'; + +// Mock all dependencies +vi.mock('@/internal/components/Skeleton'); +vi.mock('@/internal/hooks/useTheme'); +vi.mock('./SendAddressSelection'); +vi.mock('./SendAmountInput'); +vi.mock('./SendButton'); +vi.mock('./SendFundWallet'); +vi.mock('./SendHeader'); +vi.mock('./SendProvider', () => ({ + SendProvider: vi.fn(({ children }) => ( +
{children}
+ )), + useSendContext: vi.fn(), +})); +vi.mock('./SendTokenSelector'); + +const mockSelectedtoken = { + name: 'USD Coin', + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' as Address, + symbol: 'USDC', + decimals: 6, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/44/2b/442b80bd16af0c0d9b22e03a16753823fe826e5bfd457292b55fa0ba8c1ba213-ZWUzYjMZGUtMDYxNy00NDcyLTg0NjQtMWI4OGEwYjBiODE2', + chainId: 8453, + cryptoBalance: 69, + fiatBalance: 69, +}; + +describe('Send', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders with SendProvider and applies correct classes', () => { + vi.mocked(useSendContext).mockReturnValue({ + isInitialized: false, + ethBalance: 0, + selectedRecipientAddress: { value: null, display: '' }, + selectedToken: null, + } as SendContextType); + + render(); + + expect(SendProvider).toHaveBeenCalled(); + const sendContainer = screen.getByTestId('ockSend'); + expect(sendContainer).toHaveClass('h-96 w-88 flex flex-col p-4'); + }); + + it('applies custom className when provided', () => { + vi.mocked(useSendContext).mockReturnValue({ + isInitialized: false, + ethBalance: 0, + selectedRecipientAddress: { value: null, display: '' }, + selectedToken: null, + } as SendContextType); + + render(); + + const sendContainer = screen.getByTestId('ockSend'); + expect(sendContainer).toHaveClass('custom-class'); + }); + + it('renders custom children when provided', () => { + const customChildren = ( +
Custom Content
+ ); + render({customChildren}); + + expect(screen.getByTestId('custom-children')).toBeInTheDocument(); + }); + + describe('SendDefaultChildren', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders skeleton when not initialized', () => { + vi.mocked(useSendContext).mockReturnValue({ + isInitialized: false, + ethBalance: 0, + selectedRecipientAddress: { value: null, display: '' }, + selectedToken: null, + } as SendContextType); + + render(); + + expect(Skeleton).toHaveBeenCalledWith( + expect.objectContaining({ className: 'h-full w-full' }), + {}, + ); + }); + + it('renders SendFundWallet when wallet has insufficient ETH', () => { + vi.mocked(useSendContext).mockReturnValue({ + isInitialized: true, + ethBalance: 0, + } as SendContextType); + + render(); + + expect(SendHeader).toHaveBeenCalled(); + expect(SendFundWallet).toHaveBeenCalled(); + expect(SendAddressSelection).not.toHaveBeenCalled(); + }); + + it('renders SendAddressSelection when wallet has sufficient ETH', () => { + vi.mocked(useSendContext).mockReturnValue({ + isInitialized: true, + ethBalance: ETH_REQUIRED_FOR_SEND + 0.0000000001, // de-minimis amount above ETH_REQUIRED_FOR_SEND + selectedRecipientAddress: { value: null, display: '' }, + selectedToken: null, + } as SendContextType); + + render(); + + expect(SendHeader).toHaveBeenCalled(); + expect(SendAddressSelection).toHaveBeenCalled(); + expect(SendFundWallet).not.toHaveBeenCalled(); + expect(SendTokenSelector).not.toHaveBeenCalled(); + }); + + it('renders SendTokenSelector when recipient address is selected but token is not', () => { + vi.mocked(useSendContext).mockReturnValue({ + isInitialized: true, + ethBalance: ETH_REQUIRED_FOR_SEND + 0.0000000001, // de-minimis amount above ETH_REQUIRED_FOR_SEND + selectedRecipientAddress: { + value: '0x1234567890123456789012345678901234567890' as Address, + display: '0x1234567890123456789012345678901234567890', + }, + selectedToken: null, + } as SendContextType); + + render(); + + expect(SendHeader).toHaveBeenCalled(); + expect(SendAddressSelection).toHaveBeenCalled(); + expect(SendTokenSelector).toHaveBeenCalled(); + expect(SendAmountInput).not.toHaveBeenCalled(); + }); + + it('renders SendAmountInput and SendButton when both recipient and token are selected', () => { + vi.mocked(useSendContext).mockReturnValue({ + isInitialized: true, + ethBalance: ETH_REQUIRED_FOR_SEND + 0.0000001, + selectedRecipientAddress: { + value: '0x1234567890123456789012345678901234567890' as Address, + display: '0x1234567890123456789012345678901234567890', + }, + selectedToken: mockSelectedtoken, + } as SendContextType); + + render(); + + expect(SendHeader).toHaveBeenCalled(); + expect(SendAddressSelection).toHaveBeenCalled(); + expect(SendAmountInput).toHaveBeenCalled(); + expect(SendTokenSelector).toHaveBeenCalled(); + expect(SendButton).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/wallet/components/wallet-advanced-send/components/Send.tsx b/src/wallet/components/wallet-advanced-send/components/Send.tsx new file mode 100644 index 0000000000..db34c7218a --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/Send.tsx @@ -0,0 +1,72 @@ +import { Skeleton } from '@/internal/components/Skeleton'; +import { background, border, cn, color } from '@/styles/theme'; +import { ETH_REQUIRED_FOR_SEND } from '../constants'; +import type { SendReact } from '../types'; +import { SendAddressSelection } from './SendAddressSelection'; +import { SendAmountInput } from './SendAmountInput'; +import { SendButton } from './SendButton'; +import { SendFundWallet } from './SendFundWallet'; +import { SendHeader } from './SendHeader'; +import { SendProvider, useSendContext } from './SendProvider'; +import { SendTokenSelector } from './SendTokenSelector'; + +export function Send({ + children = , + className, +}: SendReact) { + return ( + +
+ {children} +
+
+ ); +} + +function SendDefaultChildren() { + const { ethBalance, isInitialized, selectedRecipientAddress, selectedToken } = + useSendContext(); + + const walletHasEth = ethBalance > ETH_REQUIRED_FOR_SEND; + + if (!isInitialized) { + return ; + } + + return ( + <> + + {walletHasEth ? ( +
+
+ + {selectedRecipientAddress.value && !selectedToken && ( + + )} +
+ {selectedRecipientAddress.value && selectedToken && ( + <> + + + + + )} +
+ ) : ( + + )} + + ); +} diff --git a/src/wallet/components/wallet-advanced-send/components/SendAddressInput.test.tsx b/src/wallet/components/wallet-advanced-send/components/SendAddressInput.test.tsx new file mode 100644 index 0000000000..98b690f7f9 --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/SendAddressInput.test.tsx @@ -0,0 +1,166 @@ +import { TextInput } from '@/internal/components/TextInput'; +import { render, screen } from '@testing-library/react'; +import type { Address } from 'viem'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resolveAddressInput } from '../utils/resolveAddressInput'; +import { validateAddressInput } from '../utils/validateAddressInput'; +import { SendAddressInput } from './SendAddressInput'; + +vi.mock('@/internal/components/TextInput', () => ({ + TextInput: vi.fn(() => ), +})); + +vi.mock('../utils/resolveAddressInput', () => ({ + resolveAddressInput: vi.fn(), +})); + +vi.mock('../utils/validateAddressInput', () => ({ + validateAddressInput: vi.fn(), +})); + +describe('SendAddressInput', () => { + const mockProps = { + selectedRecipientAddress: { value: null, display: '' }, + recipientInput: '', + setRecipientInput: vi.fn(), + setValidatedInput: vi.fn(), + handleRecipientInputChange: vi.fn(), + classNames: { + container: 'custom-container', + label: 'custom-label', + input: 'custom-input', + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders with correct structure and classes', () => { + render(); + + const container = screen.getByTestId('ockSendAddressInput'); + expect(container).toBeInTheDocument(); + expect(container).toHaveClass('custom-container'); + + const label = screen.getByText('To'); + expect(label).toBeInTheDocument(); + expect(label).toHaveClass('custom-label'); + + expect(TextInput).toHaveBeenCalledWith( + expect.objectContaining({ + className: expect.stringContaining('custom-input'), + placeholder: 'Basename, ENS, or Address', + 'aria-label': 'Input Receiver Address', + }), + {}, + ); + }); + + it('displays selectedRecipientAddress.display when available', () => { + const props = { + ...mockProps, + selectedRecipientAddress: { + value: '0x1234567890123456789012345678901234567890' as Address, + display: 'user.eth', + }, + }; + + render(); + + expect(TextInput).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'user.eth', + }), + {}, + ); + }); + + it('displays recipientInput when no selectedRecipientAddress.display', () => { + const props = { + ...mockProps, + selectedRecipientAddress: { value: null, display: '' }, + recipientInput: 'test-input', + }; + + render(); + + expect(TextInput).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'test-input', + }), + {}, + ); + }); + + it('calls handleRecipientInputChange on focus when selectedRecipientAddress.value exists', () => { + const props = { + ...mockProps, + selectedRecipientAddress: { + value: '0x1234567890123456789012345678901234567890' as Address, + display: 'user.eth', + }, + }; + + render(); + + const { onFocus } = vi.mocked(TextInput).mock.calls[0][0]; + + onFocus?.({} as React.FocusEvent); + + expect(props.handleRecipientInputChange).toHaveBeenCalled(); + }); + + it('does not call handleRecipientInputChange on focus when selectedRecipientAddress.value does not exist', () => { + render(); + + const { onFocus } = vi.mocked(TextInput).mock.calls[0][0]; + + onFocus?.({} as React.FocusEvent); + + expect(mockProps.handleRecipientInputChange).not.toHaveBeenCalled(); + }); + + it('calls setRecipientInput when TextInput setValue is called', () => { + render(); + + const { setValue } = vi.mocked(TextInput).mock.calls[0][0]; + + setValue?.('new-input'); + + expect(mockProps.setRecipientInput).toHaveBeenCalledWith('new-input'); + }); + + it('calls resolveAddressInput and setValidatedInput when input changes', async () => { + vi.mocked(resolveAddressInput).mockResolvedValue({ + value: '0x456', + display: 'resolved.eth', + }); + + render(); + + const { onChange } = vi.mocked(TextInput).mock.calls[0][0]; + + await onChange?.('new-input'); + + expect(resolveAddressInput).toHaveBeenCalledWith(null, 'new-input'); + expect(mockProps.setValidatedInput).toHaveBeenCalledWith({ + value: '0x456', + display: 'resolved.eth', + }); + }); + + it('uses validateAddressInput for input validation', () => { + vi.mocked(validateAddressInput).mockResolvedValue( + '0x1234567890123456789012345678901234567890', + ); + + render(); + + const { inputValidator } = vi.mocked(TextInput).mock.calls[0][0]; + + inputValidator?.('test-input'); + + expect(validateAddressInput).toHaveBeenCalledWith('test-input'); + }); +}); diff --git a/src/wallet/components/wallet-advanced-send/components/SendAddressInput.tsx b/src/wallet/components/wallet-advanced-send/components/SendAddressInput.tsx new file mode 100644 index 0000000000..764a95d68a --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/SendAddressInput.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { TextInput } from '@/internal/components/TextInput'; +import { background, border, cn, color } from '@/styles/theme'; +import { useCallback, useMemo } from 'react'; +import type { SendAddressInputProps } from '../types'; +import { resolveAddressInput } from '../utils/resolveAddressInput'; +import { validateAddressInput } from '../utils/validateAddressInput'; + +export function SendAddressInput({ + selectedRecipientAddress, + recipientInput, + setRecipientInput, + setValidatedInput, + handleRecipientInputChange, + classNames, +}: SendAddressInputProps) { + const displayValue = useMemo(() => { + if (selectedRecipientAddress?.display) { + return selectedRecipientAddress.display; + } + return recipientInput; + }, [selectedRecipientAddress, recipientInput]); + + const handleFocus = useCallback(() => { + if (selectedRecipientAddress.value) { + handleRecipientInputChange(); + } + }, [selectedRecipientAddress, handleRecipientInputChange]); + + const handleSetValue = useCallback( + async (input: string) => { + const resolved = await resolveAddressInput( + selectedRecipientAddress.value, + input, + ); + setValidatedInput(resolved); + }, + [selectedRecipientAddress.value, setValidatedInput], + ); + + return ( +
+ To + + !!validateAddressInput(recipientInput) + } + setValue={setRecipientInput} + onChange={handleSetValue} + onFocus={handleFocus} + aria-label="Input Receiver Address" + className={cn( + background.default, + 'w-full outline-none', + classNames?.input, + )} + /> +
+ ); +} diff --git a/src/wallet/components/wallet-advanced-send/components/SendAddressSelection.test.tsx b/src/wallet/components/wallet-advanced-send/components/SendAddressSelection.test.tsx new file mode 100644 index 0000000000..fdd1b7ef2c --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/SendAddressSelection.test.tsx @@ -0,0 +1,207 @@ +import { act, render, screen } from '@testing-library/react'; +import type { Chain } from 'viem'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useWalletContext } from '../../WalletProvider'; +import { resolveAddressInput } from '../utils/resolveAddressInput'; +import { SendAddressInput } from './SendAddressInput'; +import { SendAddressSelection } from './SendAddressSelection'; +import { SendAddressSelector } from './SendAddressSelector'; +import { useSendContext } from './SendProvider'; + +vi.mock('../../WalletProvider', () => ({ + useWalletContext: vi.fn(), +})); + +vi.mock('./SendProvider', () => ({ + useSendContext: vi.fn(), +})); + +vi.mock('./SendAddressInput', () => ({ + SendAddressInput: vi.fn(() =>
), +})); + +vi.mock('./SendAddressSelector', () => ({ + SendAddressSelector: vi.fn(() => ( +
+ )), +})); + +vi.mock('../utils/resolveAddressInput', () => ({ + resolveAddressInput: vi.fn(), +})); + +const mockChain = { + id: 8453, + name: 'Base', + nativeCurrency: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }, +} as Chain; + +const mockSendContext = { + selectedRecipientAddress: { value: null, display: '' }, + handleAddressSelection: vi.fn(), + handleRecipientInputChange: vi.fn(), +}; + +describe('SendAddressSelection', () => { + const mockUseWalletContext = useWalletContext as ReturnType; + const mockUseSendContext = useSendContext as ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseWalletContext.mockReturnValue({ + chain: mockChain, + }); + mockUseSendContext.mockReturnValue(mockSendContext); + }); + + it('renders SendAddressInput with correct props', () => { + render(); + + expect(SendAddressInput).toHaveBeenCalledWith( + expect.objectContaining({ + selectedRecipientAddress: mockSendContext.selectedRecipientAddress, + recipientInput: '', + handleRecipientInputChange: mockSendContext.handleRecipientInputChange, + }), + {}, + ); + + expect(screen.getByTestId('mock-send-address-input')).toBeInTheDocument(); + }); + + it('passes custom classNames to SendAddressInput', () => { + const customClassNames = { + input: { + container: 'custom-container', + label: 'custom-label', + input: 'custom-input', + }, + }; + + render(); + + expect(SendAddressInput).toHaveBeenCalledWith( + expect.objectContaining({ + classNames: customClassNames.input, + }), + {}, + ); + }); + + it('does not render SendAddressSelector when selectedRecipientAddress.value exists', () => { + vi.mocked(mockUseSendContext).mockReturnValue({ + ...mockSendContext, + selectedRecipientAddress: { + value: '0x1234567890123456789012345678901234567890', + display: 'user.eth', + }, + }); + + render(); + + expect(SendAddressSelector).not.toHaveBeenCalled(); + expect( + screen.queryByTestId('mock-send-address-selector'), + ).not.toBeInTheDocument(); + }); + + it('does not render SendAddressSelector when validatedInput.value is null', () => { + render(); + + expect(SendAddressSelector).not.toHaveBeenCalled(); + expect( + screen.queryByTestId('mock-send-address-selector'), + ).not.toBeInTheDocument(); + }); + + it('renders SendAddressSelector when validatedInput.value exists and no selectedRecipientAddress', async () => { + const { rerender } = render(); + const { setValidatedInput } = vi.mocked(SendAddressInput).mock.calls[0][0]; + + act(() => { + setValidatedInput({ + value: '0x1234567890123456789012345678901234567890', + display: 'user.eth', + }); + + rerender(); + }); + + expect(SendAddressSelector).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0x1234567890123456789012345678901234567890', + }), + {}, + ); + + expect( + screen.getByTestId('mock-send-address-selector'), + ).toBeInTheDocument(); + }); + + it('passes custom classNames to SendAddressSelector', async () => { + const customClassNames = { + selector: { + container: 'custom-selector-container', + }, + }; + + const { rerender } = render( + , + ); + + const { setValidatedInput } = vi.mocked(SendAddressInput).mock.calls[0][0]; + + act(() => { + setValidatedInput({ + value: '0x1234567890123456789012345678901234567890', + display: 'user.eth', + }); + + rerender(); + }); + + expect(SendAddressSelector).toHaveBeenCalledWith( + expect.objectContaining({ + classNames: customClassNames.selector, + }), + {}, + ); + }); + + it('calls resolveAddressInput and handleAddressSelection when handleClick is triggered', async () => { + vi.mocked(resolveAddressInput).mockResolvedValue({ + value: '0x9876543210987654321098765432109876543210', + display: 'resolved.eth', + }); + + const { rerender } = render(); + + const { setValidatedInput } = vi.mocked(SendAddressInput).mock.calls[0][0]; + act(() => { + setValidatedInput({ + value: '0x1234567890123456789012345678901234567890', + display: 'user.eth', + }); + + rerender(); + }); + + const { handleClick } = vi.mocked(SendAddressSelector).mock.calls[0][0]; + + await handleClick(); + + expect(resolveAddressInput).toHaveBeenCalledWith( + '0x1234567890123456789012345678901234567890', + 'user.eth', + ); + expect(mockSendContext.handleAddressSelection).toHaveBeenCalledWith({ + value: '0x9876543210987654321098765432109876543210', + display: 'resolved.eth', + }); + }); +}); diff --git a/src/wallet/components/wallet-advanced-send/components/SendAddressSelection.tsx b/src/wallet/components/wallet-advanced-send/components/SendAddressSelection.tsx new file mode 100644 index 0000000000..49a7b19396 --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/SendAddressSelection.tsx @@ -0,0 +1,77 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useWalletContext } from '../../WalletProvider'; +import type { + RecipientAddress, + SendAddressInputProps, + SendAddressSelectorProps, +} from '../types'; +import { resolveAddressInput } from '../utils/resolveAddressInput'; +import { SendAddressInput } from './SendAddressInput'; +import { SendAddressSelector } from './SendAddressSelector'; +import { useSendContext } from './SendProvider'; + +type SendAddressSelectionProps = { + classNames?: { + input?: SendAddressInputProps['classNames']; + selector?: SendAddressSelectorProps['classNames']; + }; +}; + +export function SendAddressSelection({ + classNames, +}: SendAddressSelectionProps) { + const [recipientInput, setRecipientInput] = useState(''); + const [validatedInput, setValidatedInput] = useState({ + display: '', + value: null, + }); + + const { chain: senderChain } = useWalletContext(); + const { + selectedRecipientAddress, + handleAddressSelection, + handleRecipientInputChange, + } = useSendContext(); + + const handleClick = useCallback(async () => { + const resolvedSelection = await resolveAddressInput( + validatedInput.value, + validatedInput.display, + ); + handleAddressSelection(resolvedSelection); + }, [validatedInput, handleAddressSelection]); + + const addressSelector = useMemo(() => { + if (selectedRecipientAddress.value || !validatedInput.value) { + return null; + } + return ( + + ); + }, [ + selectedRecipientAddress, + validatedInput, + senderChain, + handleClick, + classNames?.selector, + ]); + + return ( +
+ + {addressSelector} +
+ ); +} diff --git a/src/wallet/components/wallet-advanced-send/components/SendAddressSelector.test.tsx b/src/wallet/components/wallet-advanced-send/components/SendAddressSelector.test.tsx new file mode 100644 index 0000000000..ec0fb1f68c --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/SendAddressSelector.test.tsx @@ -0,0 +1,111 @@ +import { Address, Avatar, Name } from '@/identity'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { Address as AddressType, Chain } from 'viem'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SendAddressSelector } from './SendAddressSelector'; + +vi.mock('@/identity', () => ({ + Address: vi.fn(() =>
Address Component
), + Avatar: vi.fn(() =>
Avatar Component
), + Name: vi.fn(() =>
Name Component
), +})); + +const mockChain = { + id: 8453, + name: 'Base', + nativeCurrency: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }, +} as Chain; + +describe('SendAddressSelector', () => { + const mockProps = { + address: '0x1234567890123456789012345678901234567890' as AddressType, + senderChain: mockChain, + handleClick: vi.fn(), + classNames: { + container: 'custom-container', + avatar: 'custom-avatar', + name: 'custom-name', + address: 'custom-address', + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns null when address is not provided', () => { + render(); + + const container = screen.queryByTestId('ockSendAddressSelector_container'); + + expect(container).not.toBeInTheDocument(); + }); + + it('returns null when senderChain is not provided', () => { + render(); + + const container = screen.queryByTestId('ockSendAddressSelector_container'); + + expect(container).not.toBeInTheDocument(); + }); + + it('renders with correct structure and classes', () => { + render(); + + const button = screen.getByTestId('ockSendAddressSelector_button'); + expect(button).toHaveAttribute('type', 'button'); + expect(button).toHaveClass('w-full', 'text-left'); + + const container = screen.getByTestId('ockSendAddressSelector_container'); + expect(container).toBeInTheDocument(); + expect(container).toHaveClass('custom-container'); + + expect(screen.getByTestId('mock-avatar')).toBeInTheDocument(); + expect(screen.getByTestId('mock-name')).toBeInTheDocument(); + expect(screen.getByTestId('mock-address')).toBeInTheDocument(); + }); + + it('passes correct props to identity components', () => { + render(); + + expect(Avatar).toHaveBeenCalledWith( + expect.objectContaining({ + address: mockProps.address, + chain: mockProps.senderChain, + className: 'custom-avatar', + }), + {}, + ); + + expect(Name).toHaveBeenCalledWith( + expect.objectContaining({ + address: mockProps.address, + chain: mockProps.senderChain, + className: 'custom-name', + }), + {}, + ); + + expect(Address).toHaveBeenCalledWith( + expect.objectContaining({ + address: mockProps.address, + hasCopyAddressOnClick: false, + className: 'custom-address', + }), + {}, + ); + }); + + it('calls handleClick when button is clicked', () => { + render(); + + const button = screen.getByTestId('ockSendAddressSelector_button'); + fireEvent.click(button); + + expect(mockProps.handleClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/wallet/components/wallet-advanced-send/components/SendAddressSelector.tsx b/src/wallet/components/wallet-advanced-send/components/SendAddressSelector.tsx new file mode 100644 index 0000000000..4fa9a7353b --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/SendAddressSelector.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { Address, Avatar, Name } from '@/identity'; +import { background, border, cn, pressable } from '@/styles/theme'; +import type { SendAddressSelectorProps } from '../types'; + +export function SendAddressSelector({ + address, + senderChain, + handleClick, + classNames, +}: SendAddressSelectorProps) { + if (!address || !senderChain) { + return null; + } + + return ( + + ); +} diff --git a/src/wallet/components/wallet-advanced-send/components/SendAmountInput.test.tsx b/src/wallet/components/wallet-advanced-send/components/SendAmountInput.test.tsx index 5a6b4128f3..0d05a2d1ba 100644 --- a/src/wallet/components/wallet-advanced-send/components/SendAmountInput.test.tsx +++ b/src/wallet/components/wallet-advanced-send/components/SendAmountInput.test.tsx @@ -1,11 +1,14 @@ import { AmountInput } from '@/internal/components/amount-input/AmountInput'; import { render } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { SendAmountInput } from './SendAmountInput'; -import { SendAmountInputTypeSwitch } from './SendAmountInputTypeSwitch'; +import { useSendContext } from './SendProvider'; vi.mock('@/internal/components/amount-input/AmountInput'); vi.mock('./SendAmountInputTypeSwitch'); +vi.mock('./SendProvider', () => ({ + useSendContext: vi.fn(), +})); const mockToken = { symbol: 'ETH', @@ -18,36 +21,46 @@ const mockToken = { fiatBalance: 3300, }; +const defaultContext = { + selectedToken: mockToken, + cryptoAmount: '1.0', + handleCryptoAmountChange: vi.fn(), + fiatAmount: '2000', + handleFiatAmountChange: vi.fn(), + selectedInputType: 'crypto' as const, + setSelectedInputType: vi.fn(), + exchangeRate: 2000, + exchangeRateLoading: false, + className: 'test-class', + textClassName: 'test-text-class', +}; + describe('SendAmountInput', () => { beforeEach(() => { vi.clearAllMocks(); + (useSendContext as Mock).mockReturnValue(defaultContext); }); - const defaultProps = { - selectedToken: mockToken, - cryptoAmount: '1.0', - handleCryptoAmountChange: vi.fn(), - fiatAmount: '2000', - handleFiatAmountChange: vi.fn(), - selectedInputType: 'crypto' as const, - setSelectedInputType: vi.fn(), - exchangeRate: 2000, - exchangeRateLoading: false, - className: 'test-class', - textClassName: 'test-text-class', - }; - it('passes correct props to AmountInput', () => { - render(); + (useSendContext as Mock).mockReturnValue({ + ...defaultContext, + }); + + render( + , + ); expect(AmountInput).toHaveBeenCalledWith( { - fiatAmount: defaultProps.fiatAmount, - cryptoAmount: defaultProps.cryptoAmount, - asset: defaultProps.selectedToken.symbol, + fiatAmount: defaultContext.fiatAmount, + cryptoAmount: defaultContext.cryptoAmount, + asset: defaultContext.selectedToken.symbol, currency: 'USD', - selectedInputType: defaultProps.selectedInputType, - setFiatAmount: defaultProps.handleFiatAmountChange, - setCryptoAmount: defaultProps.handleCryptoAmountChange, + selectedInputType: defaultContext.selectedInputType, + setFiatAmount: defaultContext.handleFiatAmountChange, + setCryptoAmount: defaultContext.handleCryptoAmountChange, exchangeRate: '2000', className: 'test-class', textClassName: 'test-text-class', @@ -56,32 +69,20 @@ describe('SendAmountInput', () => { ); }); - it('passes correct props to SendAmountInputTypeSwitch', () => { - render(); - expect(SendAmountInputTypeSwitch).toHaveBeenCalledWith( - { - selectedToken: defaultProps.selectedToken, - fiatAmount: defaultProps.fiatAmount, - cryptoAmount: defaultProps.cryptoAmount, - selectedInputType: defaultProps.selectedInputType, - setSelectedInputType: defaultProps.setSelectedInputType, - exchangeRate: defaultProps.exchangeRate, - exchangeRateLoading: defaultProps.exchangeRateLoading, - }, - {}, - ); - }); - it('handles null/undefined values correctly', () => { + (useSendContext as Mock).mockReturnValue({ + ...defaultContext, + selectedToken: null, + fiatAmount: null, + cryptoAmount: null, + }); + render( , ); - expect(AmountInput).toHaveBeenCalledWith( { fiatAmount: '', @@ -89,26 +90,13 @@ describe('SendAmountInput', () => { asset: '', currency: 'USD', selectedInputType: 'crypto', - setFiatAmount: defaultProps.handleFiatAmountChange, - setCryptoAmount: defaultProps.handleCryptoAmountChange, + setFiatAmount: defaultContext.handleFiatAmountChange, + setCryptoAmount: defaultContext.handleCryptoAmountChange, exchangeRate: '2000', className: 'test-class', textClassName: 'test-text-class', }, {}, ); - - expect(SendAmountInputTypeSwitch).toHaveBeenCalledWith( - { - selectedToken: null, - fiatAmount: '', - cryptoAmount: '', - selectedInputType: defaultProps.selectedInputType, - setSelectedInputType: defaultProps.setSelectedInputType, - exchangeRate: defaultProps.exchangeRate, - exchangeRateLoading: defaultProps.exchangeRateLoading, - }, - {}, - ); }); }); diff --git a/src/wallet/components/wallet-advanced-send/components/SendAmountInput.tsx b/src/wallet/components/wallet-advanced-send/components/SendAmountInput.tsx index 8a6455bd3d..1756f21d5e 100644 --- a/src/wallet/components/wallet-advanced-send/components/SendAmountInput.tsx +++ b/src/wallet/components/wallet-advanced-send/components/SendAmountInput.tsx @@ -1,29 +1,35 @@ 'use client'; import { AmountInput } from '@/internal/components/amount-input/AmountInput'; -import type { SendAmountInputProps } from '../types'; import { SendAmountInputTypeSwitch } from './SendAmountInputTypeSwitch'; +import { useSendContext } from './SendProvider'; + +type SendAmountInputProps = { + className?: string; + textClassName?: string; +}; export function SendAmountInput({ - selectedToken, - cryptoAmount, - handleCryptoAmountChange, - fiatAmount, - handleFiatAmountChange, - selectedInputType, - setSelectedInputType, - exchangeRate, - exchangeRateLoading, className, textClassName, }: SendAmountInputProps) { + const { + selectedToken, + cryptoAmount, + handleCryptoAmountChange, + fiatAmount, + handleFiatAmountChange, + selectedInputType, + exchangeRate, + } = useSendContext(); + return (
- +
); } diff --git a/src/wallet/components/wallet-advanced-send/components/SendAmountInputTypeSwitch.test.tsx b/src/wallet/components/wallet-advanced-send/components/SendAmountInputTypeSwitch.test.tsx index 57fac66afe..18433cbb5b 100644 --- a/src/wallet/components/wallet-advanced-send/components/SendAmountInputTypeSwitch.test.tsx +++ b/src/wallet/components/wallet-advanced-send/components/SendAmountInputTypeSwitch.test.tsx @@ -1,11 +1,15 @@ import { Skeleton } from '@/internal/components/Skeleton'; import { AmountInputTypeSwitch } from '@/internal/components/amount-input/AmountInputTypeSwitch'; import { render } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { SendAmountInputTypeSwitch } from './SendAmountInputTypeSwitch'; +import { useSendContext } from './SendProvider'; vi.mock('@/internal/components/Skeleton'); vi.mock('@/internal/components/amount-input/AmountInputTypeSwitch'); +vi.mock('./SendProvider', () => ({ + useSendContext: vi.fn(), +})); const mockToken = { symbol: 'ETH', @@ -18,31 +22,33 @@ const mockToken = { fiatBalance: 3300, }; +const defaultContext = { + selectedToken: mockToken, + cryptoAmount: '1.0', + fiatAmount: '2000', + selectedInputType: 'crypto' as const, + setSelectedInputType: vi.fn(), + exchangeRate: 2000, + exchangeRateLoading: false, +}; + describe('SendAmountInputTypeSwitch', () => { beforeEach(() => { vi.clearAllMocks(); + (useSendContext as Mock).mockReturnValue(defaultContext); }); - const defaultProps = { - selectedToken: mockToken, - cryptoAmount: '1.0', - handleCryptoAmountChange: vi.fn(), - fiatAmount: '2000', - handleFiatAmountChange: vi.fn(), - selectedInputType: 'crypto' as const, - setSelectedInputType: vi.fn(), - exchangeRate: 2000, - exchangeRateLoading: false, - className: 'test-class', - textClassName: 'test-text-class', - loadingDisplay:
test-loading-display
, - }; - it('passes an error state when exchange rate is invalid', () => { - render(); + const mockLoadingDisplay =
test-loading-display
; + (useSendContext as Mock).mockReturnValue({ + ...defaultContext, + exchangeRate: 0, + }); + + render(); expect(AmountInputTypeSwitch).toHaveBeenCalledWith( expect.objectContaining({ - loadingDisplay:
test-loading-display
, + loadingDisplay: mockLoadingDisplay, exchangeRate: 0, }), {}, @@ -50,56 +56,70 @@ describe('SendAmountInputTypeSwitch', () => { }); it('shows skeleton when exchange rate is loading', () => { - render( - , - ); + (useSendContext as Mock).mockReturnValue({ + ...defaultContext, + exchangeRateLoading: true, + }); + + render(); expect(Skeleton).toHaveBeenCalled(); }); it('passes correct props to AmountInput', () => { - render(); + const mockLoadingDisplay =
test-loading-display
; + (useSendContext as Mock).mockReturnValue(defaultContext); + + render( + , + ); expect(AmountInputTypeSwitch).toHaveBeenCalledWith( { - asset: defaultProps.selectedToken.symbol, - fiatAmount: defaultProps.fiatAmount, - cryptoAmount: defaultProps.cryptoAmount, - exchangeRate: defaultProps.exchangeRate, - exchangeRateLoading: false, + asset: defaultContext.selectedToken.symbol, + fiatAmount: defaultContext.fiatAmount, + cryptoAmount: defaultContext.cryptoAmount, + exchangeRate: defaultContext.exchangeRate, + exchangeRateLoading: defaultContext.exchangeRateLoading, currency: 'USD', - selectedInputType: defaultProps.selectedInputType, - setSelectedInputType: defaultProps.setSelectedInputType, - className: defaultProps.className, - loadingDisplay: defaultProps.loadingDisplay, + selectedInputType: defaultContext.selectedInputType, + setSelectedInputType: defaultContext.setSelectedInputType, + className: 'test-class', + loadingDisplay:
test-loading-display
, }, {}, ); }); it('handles null/undefined values correctly', () => { - render( - , - ); + const mockSetSelectedInputType = vi.fn(); + const mockLoadingDisplay =
test-loading-display
; + + (useSendContext as Mock).mockReturnValue({ + selectedToken: null, + fiatAmount: null, + cryptoAmount: null, + exchangeRate: 3300, + exchangeRateLoading: false, + selectedInputType: 'fiat', + setSelectedInputType: mockSetSelectedInputType, + }); + + render(); expect(AmountInputTypeSwitch).toHaveBeenCalledWith( { asset: '', fiatAmount: '', cryptoAmount: '', - exchangeRate: defaultProps.exchangeRate, - exchangeRateLoading: defaultProps.exchangeRateLoading, + exchangeRate: 3300, + exchangeRateLoading: false, currency: 'USD', - selectedInputType: defaultProps.selectedInputType, - setSelectedInputType: defaultProps.setSelectedInputType, - className: defaultProps.className, - loadingDisplay: defaultProps.loadingDisplay, + selectedInputType: 'fiat', + setSelectedInputType: mockSetSelectedInputType, + className: undefined, + loadingDisplay: mockLoadingDisplay, }, {}, ); diff --git a/src/wallet/components/wallet-advanced-send/components/SendAmountInputTypeSwitch.tsx b/src/wallet/components/wallet-advanced-send/components/SendAmountInputTypeSwitch.tsx index fecf313b56..3b16f4f15b 100644 --- a/src/wallet/components/wallet-advanced-send/components/SendAmountInputTypeSwitch.tsx +++ b/src/wallet/components/wallet-advanced-send/components/SendAmountInputTypeSwitch.tsx @@ -1,38 +1,33 @@ import { Skeleton } from '@/internal/components/Skeleton'; import { AmountInputTypeSwitch } from '@/internal/components/amount-input/AmountInputTypeSwitch'; import { cn, color, text } from '@/styles/theme'; -import type { SendAmountInputProps } from '../types'; +import { useSendContext } from './SendProvider'; + +type SendAmountInputTypeSwitchProps = { + className?: string; + loadingDisplay?: React.ReactNode; +}; export function SendAmountInputTypeSwitch({ - exchangeRateLoading, loadingDisplay = (
Exchange rate unavailable
), - exchangeRate, - selectedToken, - fiatAmount, - cryptoAmount, - selectedInputType, - setSelectedInputType, className, -}: { - className?: string; - loadingDisplay?: React.ReactNode; -} & Pick< - SendAmountInputProps, - | 'exchangeRateLoading' - | 'exchangeRate' - | 'selectedToken' - | 'fiatAmount' - | 'cryptoAmount' - | 'selectedInputType' - | 'setSelectedInputType' ->) { +}: SendAmountInputTypeSwitchProps) { + const { + selectedToken, + fiatAmount, + cryptoAmount, + exchangeRate, + exchangeRateLoading, + selectedInputType, + setSelectedInputType, + } = useSendContext(); + // AmountInputTypeSwitch uses a skeleton for both loading and error states - // SendAmountInputTypeSwitch uses skeleton for the loading display - // SendAmountInputTypeSwitch uses a custom error display (see loadingDisplay default) + // SendAmountInputTypeSwitch uses skeleton for the loading display but a custom error display (see loadingDisplay default) if (exchangeRateLoading) { return ; } diff --git a/src/wallet/components/wallet-advanced-send/components/SendButton.test.tsx b/src/wallet/components/wallet-advanced-send/components/SendButton.test.tsx new file mode 100644 index 0000000000..cdd0f9e779 --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/SendButton.test.tsx @@ -0,0 +1,404 @@ +import { Transaction } from '@/transaction/components/Transaction'; +import { TransactionButton } from '@/transaction/components/TransactionButton'; +import { useTransactionContext } from '@/transaction/components/TransactionProvider'; +import { render, screen } from '@testing-library/react'; +import { type Address, type Chain, parseUnits } from 'viem'; +import { base } from 'viem/chains'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useWalletAdvancedContext } from '../../WalletAdvancedProvider'; +import { useWalletContext } from '../../WalletProvider'; +import { defaultSendTxSuccessHandler } from '../utils/defaultSendTxSuccessHandler'; +import { SendButton } from './SendButton'; +import { useSendContext } from './SendProvider'; + +vi.mock('viem', () => ({ + parseUnits: vi.fn(), +})); + +vi.mock('../../WalletProvider', () => ({ + useWalletContext: vi.fn(), +})); + +vi.mock('../../WalletAdvancedProvider', () => ({ + useWalletAdvancedContext: vi.fn(), +})); + +vi.mock('./SendProvider', () => ({ + useSendContext: vi.fn(), +})); + +vi.mock('@/transaction/components/TransactionProvider', () => ({ + useTransactionContext: vi.fn(), +})); + +vi.mock('@/transaction/components/Transaction', () => ({ + Transaction: vi.fn(({ children }) => ( +
{children}
+ )), +})); + +vi.mock('@/transaction/components/TransactionButton', () => ({ + TransactionButton: vi.fn(() => ( + + )), +})); + +vi.mock('@/transaction/components/TransactionStatus', () => ({ + TransactionStatus: vi.fn(({ children }) => ( +
{children}
+ )), +})); + +vi.mock('@/transaction/components/TransactionStatusLabel', () => ({ + TransactionStatusLabel: vi.fn(() => ( +
Status
+ )), +})); + +vi.mock('@/transaction/components/TransactionStatusAction', () => ({ + TransactionStatusAction: vi.fn(() => ( +
Action
+ )), +})); + +vi.mock('../utils/defaultSendTxSuccessHandler', () => ({ + defaultSendTxSuccessHandler: vi.fn(() => vi.fn()), +})); + +const mockChain = { + id: 8453, + name: 'Base', + nativeCurrency: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }, +} as Chain; + +const mockSelectedtoken = { + name: 'USD Coin', + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' as Address, + symbol: 'USDC', + decimals: 6, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/44/2b/442b80bd16af0c0d9b22e03a16753823fe826e5bfd457292b55fa0ba8c1ba213-ZWUzYjMZGUtMDYxNy00NDcyLTg0NjQtMWI4OGEwYjBiODE2', + chainId: 8453, + cryptoBalance: 69000000, + fiatBalance: 69000000, +}; + +describe('SendButton', () => { + const mockUseWalletContext = useWalletContext as ReturnType; + const mockUseWalletAdvancedContext = useWalletAdvancedContext as ReturnType< + typeof vi.fn + >; + const mockUseSendContext = useSendContext as ReturnType; + const mockUseTransactionContext = useTransactionContext as ReturnType< + typeof vi.fn + >; + const mockDefaultSendTxSuccessHandler = + defaultSendTxSuccessHandler as ReturnType; + + const mockWalletContext = { + chain: mockChain, + address: '0x1234567890123456789012345678901234567890', + }; + + const mockWalletAdvancedContext = { + setActiveFeature: vi.fn(), + }; + + const mockSendContext = { + callData: { to: '0x9876543210987654321098765432109876543210', data: '0x' }, + cryptoAmount: '1.0', + selectedToken: mockSelectedtoken, + updateLifecycleStatus: vi.fn(), + }; + + const mockTransactionContext = { + transactionHash: '0xabcdef', + transactionId: '123', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseWalletContext.mockReturnValue(mockWalletContext); + mockUseWalletAdvancedContext.mockReturnValue(mockWalletAdvancedContext); + mockUseSendContext.mockReturnValue(mockSendContext); + mockUseTransactionContext.mockReturnValue(mockTransactionContext); + }); + + it('renders with default props', () => { + render(); + + expect(Transaction).toHaveBeenCalledWith( + expect.objectContaining({ + isSponsored: false, + chainId: mockChain.id, + calls: [mockSendContext.callData], + }), + {}, + ); + + expect(screen.getByTestId('mock-transaction')).toBeInTheDocument(); + expect(screen.getByTestId('mock-transaction-button')).toBeInTheDocument(); + expect(screen.getByTestId('mock-transaction-status')).toBeInTheDocument(); + expect( + screen.getByTestId('mock-transaction-status-label'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('mock-transaction-status-action'), + ).toBeInTheDocument(); + }); + + it('passes custom label to TransactionButton', () => { + render(); + + expect(TransactionButton).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Custom Send', + }), + {}, + ); + }); + + it('passes isSponsored prop to Transaction', () => { + render(); + + expect(Transaction).toHaveBeenCalledWith( + expect.objectContaining({ + isSponsored: true, + }), + {}, + ); + }); + + it('passes className to TransactionButton', () => { + render(); + + expect(TransactionButton).toHaveBeenCalledWith( + expect.objectContaining({ + className: 'custom-button-class', + }), + {}, + ); + }); + + it('disables button when input amount is invalid', () => { + mockUseSendContext.mockReturnValue({ + ...mockSendContext, + cryptoAmount: '3.0', // More than balance + }); + + render(); + + expect(TransactionButton).toHaveBeenCalledWith( + expect.objectContaining({ + disabled: true, + }), + {}, + ); + }); + + it('disables button when disabled prop is true', () => { + render(); + + expect(TransactionButton).toHaveBeenCalledWith( + expect.objectContaining({ + disabled: true, + }), + {}, + ); + }); + + it('uses default chain when wallet chain is null', () => { + mockUseWalletContext.mockReturnValue({ + ...mockWalletContext, + chain: null, + }); + + render(); + + expect(Transaction).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: base.id, + }), + {}, + ); + }); + + it('uses empty calls array when callData is null', () => { + mockUseSendContext.mockReturnValue({ + ...mockSendContext, + callData: null, + }); + + render(); + + expect(Transaction).toHaveBeenCalledWith( + expect.objectContaining({ + calls: [], + }), + {}, + ); + }); + + it('sets button label to "Input amount" when cryptoAmount is null', () => { + mockUseSendContext.mockReturnValue({ + ...mockSendContext, + cryptoAmount: null, + }); + + render(); + + expect(TransactionButton).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Input amount', + }), + {}, + ); + }); + + it('sets button label to "Select token" when selectedToken is null', () => { + mockUseSendContext.mockReturnValue({ + ...mockSendContext, + selectedToken: null, + }); + + render(); + + expect(TransactionButton).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Select token', + }), + {}, + ); + }); + + it('sets button label to "Insufficient balance" when amount exceeds balance', () => { + mockUseSendContext.mockReturnValue({ + ...mockSendContext, + cryptoAmount: '3.0', // More than the 2 ETH balance + }); + + vi.mocked(parseUnits).mockImplementation(() => { + return 3000000000000000000n; + }); + + render(); + + expect(TransactionButton).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Insufficient balance', + }), + {}, + ); + }); + + it('sets button label to "Continue" when all conditions are met', () => { + vi.mocked(parseUnits).mockImplementation(() => { + return 1000000n; + }); + + render(); + + expect(TransactionButton).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Continue', + }), + {}, + ); + }); + + it('calls updateLifecycleStatus when transaction status changes', () => { + render(); + + const { onStatus } = vi.mocked(Transaction).mock.calls[0][0]; + + onStatus?.({ statusName: 'transactionPending', statusData: null }); + + expect(mockSendContext.updateLifecycleStatus).toHaveBeenCalledWith({ + statusName: 'transactionPending', + statusData: null, + }); + }); + + it('does not call updateLifecycleStatus for non-tracked statuses', () => { + render(); + + const { onStatus } = vi.mocked(Transaction).mock.calls[0][0]; + + // @ts-expect-error - setting invalid status name for testing + onStatus?.({ statusName: 'someOtherStatus', statusData: null }); + + expect(mockSendContext.updateLifecycleStatus).not.toHaveBeenCalled(); + }); + + it('passes custom overrides to TransactionButton', () => { + const pendingOverride = { text: 'Sending...' }; + const successOverride = { text: 'Sent!' }; + const errorOverride = { text: 'Failed!' }; + + render( + , + ); + + expect(TransactionButton).toHaveBeenCalledWith( + expect.objectContaining({ + pendingOverride, + successOverride, + errorOverride, + }), + {}, + ); + }); + + it('handles null wallet address correctly', () => { + mockUseWalletContext.mockReturnValue({ + ...mockWalletContext, + address: null, + }); + + render(); + + expect(defaultSendTxSuccessHandler).toHaveBeenCalledWith( + expect.objectContaining({ + address: undefined, + }), + ); + }); + + it('configures defaultSuccessOverride with correct parameters', () => { + render(); + + expect(defaultSendTxSuccessHandler).toHaveBeenCalledWith({ + transactionId: '123', + transactionHash: '0xabcdef', + senderChain: mockWalletContext.chain, + address: mockWalletContext.address, + onComplete: expect.any(Function), + }); + }); + + it('calls setActiveFeature when completionHandler is triggered', () => { + const setActiveFeature = vi.fn(); + mockUseWalletAdvancedContext.mockReturnValue({ + setActiveFeature, + }); + + render(); + + const { onComplete } = mockDefaultSendTxSuccessHandler.mock.calls[0][0]; + + // Call the callback + onComplete(); + + expect(setActiveFeature).toHaveBeenCalledWith(null); + }); +}); diff --git a/src/wallet/components/wallet-advanced-send/components/SendButton.tsx b/src/wallet/components/wallet-advanced-send/components/SendButton.tsx new file mode 100644 index 0000000000..8a6b624348 --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/SendButton.tsx @@ -0,0 +1,196 @@ +'use client'; + +import type { PortfolioTokenWithFiatValue } from '@/api/types'; +import type { LifecycleStatusUpdate } from '@/internal/types'; +import { Transaction } from '@/transaction/components/Transaction'; +import { TransactionButton } from '@/transaction/components/TransactionButton'; +import { useTransactionContext } from '@/transaction/components/TransactionProvider'; +import { TransactionStatus } from '@/transaction/components/TransactionStatus'; +import { TransactionStatusAction } from '@/transaction/components/TransactionStatusAction'; +import { TransactionStatusLabel } from '@/transaction/components/TransactionStatusLabel'; +import type { + LifecycleStatus, + TransactionButtonReact, +} from '@/transaction/types'; +import { useCallback } from 'react'; +import { parseUnits } from 'viem'; +import { type Chain, base } from 'viem/chains'; +import { useWalletAdvancedContext } from '../../WalletAdvancedProvider'; +import { useWalletContext } from '../../WalletProvider'; +import type { SendLifecycleStatus } from '../types'; +import { defaultSendTxSuccessHandler } from '../utils/defaultSendTxSuccessHandler'; +import { useSendContext } from './SendProvider'; + +type SendButtonProps = { + label?: string; + isSponsored?: boolean; + className?: string; +} & Pick< + TransactionButtonReact, + 'disabled' | 'pendingOverride' | 'successOverride' | 'errorOverride' +>; + +export function SendButton({ + label, + isSponsored = false, + className, + disabled, + pendingOverride, + successOverride, + errorOverride, +}: SendButtonProps) { + const { chain: senderChain } = useWalletContext(); + const { + callData, + cryptoAmount: inputAmount, + selectedToken, + updateLifecycleStatus, + } = useSendContext(); + + const disableSendButton = + disabled ?? + !validateAmountInput({ + inputAmount: inputAmount ?? '', + balance: BigInt(selectedToken?.cryptoBalance ?? 0), + selectedToken: selectedToken ?? undefined, + }); + + const buttonLabel = + label ?? getDefaultSendButtonLabel(inputAmount, selectedToken); + + const handleStatus = useCallback( + (status: LifecycleStatus) => { + const validStatuses = [ + 'transactionPending', + 'transactionLegacyExecuted', + 'success', + 'error', + ] as const; + if ( + validStatuses.includes( + status.statusName as (typeof validStatuses)[number], + ) + ) { + updateLifecycleStatus( + status as LifecycleStatusUpdate, + ); + } + }, + [updateLifecycleStatus], + ); + + return ( + + + + + + + + ); +} + +/** + * SendTransactionButton required to be a nested component in order to pull from TransactionContext. + * Need to pull from TransactionContext in order to get transactionHash and transactionId. + * Need transactionHash and transactionId in order to determine where to open the transaction in the wallet or explorer. + */ +function SendTransactionButton({ + label, + senderChain, + disabled, + pendingOverride, + successOverride, + errorOverride, + className, +}: { + label: string; + senderChain?: Chain | null; + disabled?: boolean; + pendingOverride?: TransactionButtonReact['pendingOverride']; + successOverride?: TransactionButtonReact['successOverride']; + errorOverride?: TransactionButtonReact['errorOverride']; + className?: string; +}) { + const { address } = useWalletContext(); + const { setActiveFeature } = useWalletAdvancedContext(); + const { transactionHash, transactionId } = useTransactionContext(); + + const completionHandler = useCallback(() => { + setActiveFeature(null); + }, [setActiveFeature]); + + const defaultSuccessOverride = { + onClick: defaultSendTxSuccessHandler({ + transactionId, + transactionHash, + senderChain: senderChain ?? undefined, + address: address ?? undefined, + onComplete: completionHandler, + }), + }; + + return ( + + ); +} + +function getDefaultSendButtonLabel( + cryptoAmount: string | null, + selectedToken: PortfolioTokenWithFiatValue | null, +) { + if (!cryptoAmount) { + return 'Input amount'; + } + + if (!selectedToken) { + return 'Select token'; + } + + if ( + parseUnits(cryptoAmount, selectedToken.decimals) > + selectedToken.cryptoBalance + ) { + return 'Insufficient balance'; + } + + return 'Continue'; +} + +function validateAmountInput({ + inputAmount, + balance, + selectedToken, +}: { + inputAmount?: string; + balance?: bigint; + selectedToken?: PortfolioTokenWithFiatValue; +}) { + if (!inputAmount || !selectedToken || !balance) { + return false; + } + + const parsedCryptoAmount = parseUnits(inputAmount, selectedToken.decimals); + + return parsedCryptoAmount > 0n && parsedCryptoAmount <= balance; +} diff --git a/src/wallet/components/wallet-advanced-send/components/SendFundWallet.tsx b/src/wallet/components/wallet-advanced-send/components/SendFundWallet.tsx index 607d586116..9d9299d1a7 100644 --- a/src/wallet/components/wallet-advanced-send/components/SendFundWallet.tsx +++ b/src/wallet/components/wallet-advanced-send/components/SendFundWallet.tsx @@ -7,14 +7,24 @@ import { FundCardSubmitButton, } from '@/fund'; import { cn, color, text } from '@/styles/theme'; -import type { SendFundingWalletProps } from '../types'; + +type SendFundWalletProps = { + onError?: () => void; + onStatus?: () => void; + onSuccess?: () => void; + classNames?: { + container?: string; + subtitle?: string; + fundCard?: string; + }; +}; export function SendFundWallet({ onError, onStatus, onSuccess, classNames, -}: SendFundingWalletProps) { +}: SendFundWalletProps) { return (
({ + useWalletAdvancedContext: vi.fn(), +})); + +vi.mock('./SendProvider', () => ({ + useSendContext: vi.fn(), +})); + +vi.mock('@/internal/components/PressableIcon', () => ({ + PressableIcon: vi.fn(({ children, onClick, className }) => ( + + )), +})); + +vi.mock('@/internal/svg/backArrowSvg', () => ({ + backArrowSvg:
Back Arrow
, +})); + +vi.mock('@/internal/svg/closeSvg', () => ({ + CloseSvg: () =>
Close
, +})); + +describe('SendHeader', () => { + const mockUseWalletAdvancedContext = useWalletAdvancedContext as ReturnType< + typeof vi.fn + >; + const mockUseSendContext = useSendContext as ReturnType; + + const mockWalletAdvancedContext = { + setActiveFeature: vi.fn(), + }; + + const mockSendContext = { + selectedRecipientAddress: { value: null, display: null }, + selectedToken: null, + handleResetTokenSelection: vi.fn(), + handleRecipientInputChange: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseWalletAdvancedContext.mockReturnValue(mockWalletAdvancedContext); + mockUseSendContext.mockReturnValue(mockSendContext); + }); + + it('renders with default label', () => { + render(); + + expect(screen.getByText('Send')).toBeInTheDocument(); + expect(screen.getByTestId('mock-close-svg')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-back-arrow')).not.toBeInTheDocument(); + }); + + it('renders with custom label', () => { + render(); + + expect(screen.getByText('Custom Send')).toBeInTheDocument(); + }); + + it('applies custom classNames', () => { + const customClassNames = { + container: 'custom-container', + label: 'custom-label', + close: 'custom-close', + back: 'custom-back', + }; + + mockUseSendContext.mockReturnValue({ + ...mockSendContext, + selectedRecipientAddress: { value: '0x123', display: 'user.eth' }, + }); + + render(); + + const container = screen.queryByTestId('ockSendHeader'); + expect(container).toHaveClass('custom-container'); + + const label = screen.queryByTestId('ockSendHeader_label'); + expect(label).toHaveClass('custom-label'); + + const backButton = screen.queryByTestId('ockSendHeader_back'); + expect(backButton?.firstElementChild).toHaveClass('custom-back'); + + const closeButton = screen.queryByTestId('ockSendHeader_close'); + expect(closeButton?.firstElementChild).toHaveClass('custom-close'); + }); + + it('shows back button when recipient address is selected', () => { + mockUseSendContext.mockReturnValue({ + ...mockSendContext, + selectedRecipientAddress: { + value: '0x1234567890123456789012345678901234567890', + display: 'user.eth', + }, + }); + + render(); + + expect(screen.getByTestId('mock-back-arrow')).toBeInTheDocument(); + }); + + it('calls handleClose when close button is clicked', () => { + render(); + + const closeButton = screen.getByTestId('mock-pressable-icon'); + fireEvent.click(closeButton); + + expect(mockWalletAdvancedContext.setActiveFeature).toHaveBeenCalledWith( + null, + ); + }); + + it('calls handleResetTokenSelection when back button is clicked and token is selected', () => { + mockUseSendContext.mockReturnValue({ + ...mockSendContext, + selectedRecipientAddress: { value: '0x123', display: 'user.eth' }, + selectedToken: { symbol: 'ETH' }, + }); + + render(); + + const backButton = screen.getByTestId('mock-back-arrow'); + fireEvent.click(backButton); + + expect(mockSendContext.handleResetTokenSelection).toHaveBeenCalled(); + expect(mockSendContext.handleRecipientInputChange).not.toHaveBeenCalled(); + }); + + it('calls handleRecipientInputChange when back button is clicked and no token is selected', () => { + mockUseSendContext.mockReturnValue({ + ...mockSendContext, + selectedRecipientAddress: { value: '0x123', display: 'user.eth' }, + selectedToken: null, + }); + + render(); + + const backButton = screen.getByTestId('mock-back-arrow'); + fireEvent.click(backButton); + + expect(mockSendContext.handleRecipientInputChange).toHaveBeenCalled(); + expect(mockSendContext.handleResetTokenSelection).not.toHaveBeenCalled(); + }); +}); diff --git a/src/wallet/components/wallet-advanced-send/components/SendHeader.tsx b/src/wallet/components/wallet-advanced-send/components/SendHeader.tsx new file mode 100644 index 0000000000..bdf92ec66e --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/SendHeader.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { PressableIcon } from '@/internal/components/PressableIcon'; +import { backArrowSvg } from '@/internal/svg/backArrowSvg'; +import { CloseSvg } from '@/internal/svg/closeSvg'; +import { cn, text } from '@/styles/theme'; +import { useCallback } from 'react'; +import { useWalletAdvancedContext } from '../../WalletAdvancedProvider'; +import { useSendContext } from './SendProvider'; + +type SendHeaderProps = { + label?: string; + classNames?: { + container?: string; + label?: string; + close?: string; + back?: string; + }; +}; + +export function SendHeader({ label = 'Send', classNames }: SendHeaderProps) { + const { setActiveFeature } = useWalletAdvancedContext(); + + const { + selectedRecipientAddress, + selectedToken, + handleResetTokenSelection, + handleRecipientInputChange, + } = useSendContext(); + + const handleBack = useCallback(() => { + if (selectedToken) { + handleResetTokenSelection(); + } else if (selectedRecipientAddress.value) { + handleRecipientInputChange(); + } + }, [ + selectedRecipientAddress, + selectedToken, + handleResetTokenSelection, + handleRecipientInputChange, + ]); + + const handleClose = useCallback(() => { + setActiveFeature(null); + }, [setActiveFeature]); + + return ( +
+
+ {selectedRecipientAddress.value && ( + + {backArrowSvg} + + )} +
+
+ {label} +
+
+ + + +
+
+ ); +} diff --git a/src/wallet/components/wallet-advanced-send/components/SendProvider.test.tsx b/src/wallet/components/wallet-advanced-send/components/SendProvider.test.tsx new file mode 100644 index 0000000000..c811a3cf42 --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/SendProvider.test.tsx @@ -0,0 +1,670 @@ +import type { APIError } from '@/api/types'; +import { useExchangeRate } from '@/internal/hooks/useExchangeRate'; +import { useSendTransaction } from '@/internal/hooks/useSendTransaction'; +import { act, render, renderHook } from '@testing-library/react'; +import { formatUnits } from 'viem'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useWalletAdvancedContext } from '../../WalletAdvancedProvider'; +import { SendProvider, useSendContext } from './SendProvider'; + +vi.mock('../../WalletAdvancedProvider', () => ({ + useWalletAdvancedContext: vi.fn(), +})); + +vi.mock('@/internal/hooks/useExchangeRate', () => ({ + useExchangeRate: vi.fn().mockReturnValue(Promise.resolve()), +})); + +vi.mock('@/internal/hooks/useSendTransaction', () => ({ + useSendTransaction: vi.fn(), +})); + +vi.mock('viem', () => ({ + formatUnits: vi.fn(), +})); + +describe('useSendContext', () => { + const mockUseWalletAdvancedContext = useWalletAdvancedContext as ReturnType< + typeof vi.fn + >; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseWalletAdvancedContext.mockReturnValue({ + tokenBalances: [ + { + address: '', + symbol: 'ETH', + decimals: 18, + cryptoBalance: '2000000000000000000', + fiatBalance: 4000, + }, + ], + }); + + vi.mocked(formatUnits).mockReturnValue('2'); + vi.mocked(useSendTransaction).mockReturnValue({ + to: '0x1234', + data: '0x', + }); + }); + + it('should provide send context when used within provider', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + expect(result.current).toEqual({ + isInitialized: expect.any(Boolean), + lifecycleStatus: expect.any(Object), + updateLifecycleStatus: expect.any(Function), + ethBalance: expect.any(Number), + selectedRecipientAddress: expect.any(Object), + handleAddressSelection: expect.any(Function), + selectedToken: null, + handleRecipientInputChange: expect.any(Function), + handleTokenSelection: expect.any(Function), + handleResetTokenSelection: expect.any(Function), + fiatAmount: null, + handleFiatAmountChange: expect.any(Function), + cryptoAmount: null, + handleCryptoAmountChange: expect.any(Function), + exchangeRate: expect.any(Number), + exchangeRateLoading: expect.any(Boolean), + selectedInputType: 'crypto', + setSelectedInputType: expect.any(Function), + callData: null, + }); + }); + + it('should throw an error when used outside of SendProvider', () => { + const TestComponent = () => { + useSendContext(); + return null; + }; + + const originalError = console.error; + console.error = vi.fn(); + + expect(() => { + render(); + }).toThrow(); + + console.error = originalError; + }); + + it('should initialize and set lifecycle status when the user has an ETH balance', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + expect(result.current.ethBalance).toBe(2); + expect(result.current.lifecycleStatus.statusName).toBe('selectingAddress'); + }); + + it('should initialize and set lifecycle status when the user does not have an ETH balance', () => { + mockUseWalletAdvancedContext.mockReturnValue({ + tokenBalances: [ + { + address: '0x0000000000000000000000000000000000000000', + symbol: 'USDC', + decimals: 6, + cryptoBalance: '2000000000000000000', + fiatBalance: 4000, + }, + ], + }); + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + expect(result.current.ethBalance).toBe(0); + expect(result.current.lifecycleStatus.statusName).toBe('fundingWallet'); + }); + + it('should handle address selection', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + act(() => { + result.current.handleAddressSelection({ + display: 'user.eth', + value: '0x1234', + }); + }); + + expect(result.current.selectedRecipientAddress).toEqual({ + display: 'user.eth', + value: '0x1234', + }); + expect(result.current.lifecycleStatus.statusName).toBe('selectingToken'); + }); + + it('should handle recipient input change', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + act(() => { + result.current.handleAddressSelection({ + display: 'user.eth', + value: '0x1234', + }); + }); + + expect(result.current.selectedRecipientAddress).toEqual({ + display: 'user.eth', + value: '0x1234', + }); + + act(() => { + result.current.handleRecipientInputChange(); + }); + + expect(result.current.selectedRecipientAddress).toEqual({ + display: '', + value: null, + }); + + expect(result.current.lifecycleStatus.statusName).toBe('selectingAddress'); + expect(result.current.lifecycleStatus.statusData).toEqual({ + isMissingRequiredField: true, + }); + }); + + it('should handle token selection', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + const token = { + name: 'Ethereum', + symbol: 'ETH', + address: '' as const, + decimals: 18, + cryptoBalance: 200000000000000, + fiatBalance: 4000, + chainId: 8453, + image: '', + }; + + act(() => { + result.current.handleTokenSelection(token); + }); + + expect(result.current.selectedToken).toEqual(token); + expect(result.current.lifecycleStatus.statusName).toBe('amountChange'); + }); + + it('should handle reset token selection', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + const token = { + name: 'Ethereum', + symbol: 'ETH', + address: '' as const, + decimals: 18, + cryptoBalance: 200000000000000, + fiatBalance: 4000, + chainId: 8453, + image: '', + }; + + act(() => { + result.current.handleTokenSelection(token); + result.current.handleResetTokenSelection(); + }); + + expect(result.current.selectedToken).toBeNull(); + expect(result.current.fiatAmount).toBeNull(); + expect(result.current.cryptoAmount).toBeNull(); + expect(result.current.lifecycleStatus.statusName).toBe('selectingToken'); + }); + + it('should handle crypto amount change', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + const token = { + name: 'Ethereum', + symbol: 'ETH', + address: '' as const, + decimals: 18, + cryptoBalance: 200000000000000, + fiatBalance: 4000, + chainId: 8453, + image: '', + }; + + act(() => { + result.current.handleTokenSelection(token); + result.current.handleCryptoAmountChange('1.0'); + }); + + expect(result.current.cryptoAmount).toBe('1.0'); + expect(result.current.lifecycleStatus.statusName).toBe('amountChange'); + }); + + it('should handle fiat amount change', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + const token = { + name: 'Ethereum', + symbol: 'ETH', + address: '' as const, + decimals: 18, + cryptoBalance: 200000000000000, + fiatBalance: 4000, + chainId: 8453, + image: '', + }; + + act(() => { + result.current.handleTokenSelection(token); + result.current.handleFiatAmountChange('1000'); + }); + + expect(result.current.fiatAmount).toBe('1000'); + expect(result.current.lifecycleStatus.statusName).toBe('amountChange'); + }); + + it('should handle BigInt conversion in crypto amount change', () => { + const formatUnitsSpy = vi.mocked(formatUnits); + formatUnitsSpy.mockClear(); + + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + const token = { + name: 'Test Token', + symbol: 'TEST', + address: '0x123' as const, + decimals: 18, + cryptoBalance: 100000000000000, + fiatBalance: 1000, + chainId: 8453, + image: '', + }; + + act(() => { + result.current.handleTokenSelection(token); + result.current.handleCryptoAmountChange('1.0'); + }); + + expect(formatUnitsSpy).toHaveBeenCalledWith(expect.any(BigInt), 18); + + const tokenWithNullValues = { + name: 'Test Token', + symbol: 'TEST', + address: '0x123' as const, + decimals: undefined as unknown as number, + cryptoBalance: undefined as unknown as number, + fiatBalance: undefined as unknown as number, + chainId: 8453, + image: '', + }; + + act(() => { + result.current.handleTokenSelection(tokenWithNullValues); + }); + + formatUnitsSpy.mockClear(); + + act(() => { + result.current.handleCryptoAmountChange('1.0'); + }); + + expect(formatUnitsSpy).toHaveBeenCalledWith(BigInt(0), 0); + }); + + it('should set sufficientBalance correctly when token has fiat balance', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + act(() => { + result.current.handleFiatAmountChange('1000'); + }); + + // @ts-ignore - test is not type narrowing + expect(result.current.lifecycleStatus.statusData?.sufficientBalance).toBe( + false, + ); + + const token = { + name: 'Ethereum', + symbol: 'ETH', + address: '' as const, + decimals: 18, + cryptoBalance: 200000000000000, + fiatBalance: 4000, + chainId: 8453, + image: '', + }; + + act(() => { + result.current.handleTokenSelection(token); + }); + + act(() => { + result.current.handleFiatAmountChange('3999.99'); + }); + expect(result.current.fiatAmount).toBe('3999.99'); + // @ts-ignore - test is not type narrowing + expect(result.current.lifecycleStatus.statusData?.sufficientBalance).toBe( + true, + ); + + act(() => { + result.current.handleFiatAmountChange('4000'); + }); + expect(result.current.fiatAmount).toBe('4000'); + // @ts-ignore - test is not type narrowing + expect(result.current.lifecycleStatus.statusData?.sufficientBalance).toBe( + true, + ); + + act(() => { + result.current.handleFiatAmountChange('4000.01'); + }); + expect(result.current.fiatAmount).toBe('4000.01'); + // @ts-ignore - test is not type narrowing + expect(result.current.lifecycleStatus.statusData?.sufficientBalance).toBe( + false, + ); + }); + + it('should set sufficientBalance correctly in handleFiatAmountChange', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + act(() => { + result.current.handleFiatAmountChange('1000'); + }); + // @ts-ignore - test is not type narrowing + expect(result.current.lifecycleStatus.statusData?.sufficientBalance).toBe( + false, + ); + + const tokenWithNullFiatBalance = { + name: 'Ethereum', + symbol: 'ETH', + address: '' as const, + decimals: 18, + cryptoBalance: 200000000000000, + fiatBalance: undefined as unknown as number, + chainId: 8453, + image: '', + }; + + const tokenWithoutBalance = { + ...tokenWithNullFiatBalance, + }; + + act(() => { + result.current.handleTokenSelection(tokenWithoutBalance); + result.current.handleFiatAmountChange('100'); + }); + expect(result.current.fiatAmount).toBe('100'); + // @ts-ignore - test is not type narrowing + expect(result.current.lifecycleStatus.statusData?.sufficientBalance).toBe( + false, + ); + }); + + it('should handle input type change', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + act(() => { + result.current.setSelectedInputType('fiat'); + }); + + expect(result.current.selectedInputType).toBe('fiat'); + }); + + it('should call useExchangeRate with correct parameters when ETH token is selected', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + const ethToken = { + name: 'Ethereum', + symbol: 'ETH', + address: '' as const, + decimals: 18, + cryptoBalance: 200000000000000, + fiatBalance: 4000, + chainId: 8453, + image: '', + }; + + act(() => { + result.current.handleTokenSelection(ethToken); + }); + + expect(useExchangeRate).toHaveBeenCalledWith({ + token: 'ETH', + selectedInputType: 'crypto', + setExchangeRate: expect.any(Function), + setExchangeRateLoading: expect.any(Function), + }); + }); + + it('should call useExchangeRate with token address for non-ETH tokens', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + const usdcToken = { + name: 'USD Coin', + symbol: 'USDC', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as const, + decimals: 6, + cryptoBalance: 5000000, + fiatBalance: 5, + chainId: 8453, + image: '', + }; + + act(() => { + result.current.handleTokenSelection(usdcToken); + }); + + expect(useExchangeRate).toHaveBeenCalledWith({ + token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + selectedInputType: 'crypto', + setExchangeRate: expect.any(Function), + setExchangeRateLoading: expect.any(Function), + }); + }); + + it('should call useExchangeRate when selectedInputType changes', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + const ethToken = { + name: 'Ethereum', + symbol: 'ETH', + address: '' as const, + decimals: 18, + cryptoBalance: 200000000000000, + fiatBalance: 4000, + chainId: 8453, + image: '', + }; + + act(() => { + result.current.handleTokenSelection(ethToken); + }); + + vi.clearAllMocks(); + + act(() => { + result.current.setSelectedInputType('fiat'); + }); + + expect(useExchangeRate).toHaveBeenCalledWith({ + token: 'ETH', + selectedInputType: 'fiat', + setExchangeRate: expect.any(Function), + setExchangeRateLoading: expect.any(Function), + }); + }); + + it('should not call useExchangeRate if selectedToken has invalid parameters', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + const mockToken = { + name: 'Mock Token', + symbol: 'MOCK', + address: '' as const, + decimals: 18, + cryptoBalance: 200000000000000, + fiatBalance: 4000, + chainId: 8453, + image: '', + }; + + vi.clearAllMocks(); + + act(() => { + result.current.handleTokenSelection(mockToken); + }); + + expect(useExchangeRate).not.toHaveBeenCalled(); + }); + + it('should fetch transaction data when all required fields are set', () => { + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + const token = { + name: 'Ethereum', + symbol: 'ETH', + address: '' as const, + decimals: 18, + cryptoBalance: 200000000000000, + fiatBalance: 4000, + chainId: 8453, + image: '', + }; + + act(() => { + result.current.handleAddressSelection({ + display: 'user.eth', + value: '0x1234', + }); + result.current.handleTokenSelection(token); + result.current.handleCryptoAmountChange('1.0'); + }); + + expect(useSendTransaction).toHaveBeenCalledWith({ + recipientAddress: '0x1234', + token, + amount: '1.0', + }); + + expect(result.current.callData).toEqual({ + to: '0x1234', + data: '0x', + }); + }); + + it('should handle transaction error when useSendTransaction returns an error', () => { + vi.mocked(useSendTransaction).mockReturnValue({ + code: 'SeMSeBC01', // Send module SendButton component 01 error + error: 'Transaction failed', + message: 'Error: Transaction failed', + }); + + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + const token = { + name: 'Ethereum', + symbol: 'ETH', + address: '' as const, + decimals: 18, + cryptoBalance: 200000000000000, + fiatBalance: 4000, + chainId: 8453, + image: '', + }; + + act(() => { + result.current.handleAddressSelection({ + display: 'user.eth', + value: '0x1234', + }); + result.current.handleTokenSelection(token); + result.current.handleCryptoAmountChange('1.0'); + }); + + expect(result.current.lifecycleStatus.statusName).toBe('error'); + expect((result.current.lifecycleStatus.statusData as APIError).code).toBe( + 'SeMSeBC01', + ); + expect((result.current.lifecycleStatus.statusData as APIError).error).toBe( + 'Error building send transaction: Transaction failed', + ); + expect( + (result.current.lifecycleStatus.statusData as APIError).message, + ).toBe('Error: Transaction failed'); + }); + + it('should handle transaction error when useSendTransaction throws', () => { + vi.mocked(useSendTransaction).mockImplementation(() => { + throw new Error('Uncaught send transaction error'); + }); + + const { result } = renderHook(() => useSendContext(), { + wrapper: SendProvider, + }); + + const token = { + name: 'Ethereum', + symbol: 'ETH', + address: '' as const, + decimals: 18, + cryptoBalance: 200000000000000, + fiatBalance: 4000, + chainId: 8453, + image: '', + }; + + act(() => { + result.current.handleAddressSelection({ + display: 'user.eth', + value: '0x1234', + }); + result.current.handleTokenSelection(token); + result.current.handleCryptoAmountChange('1.0'); + }); + + expect(result.current.lifecycleStatus.statusName).toBe('error'); + expect((result.current.lifecycleStatus.statusData as APIError).code).toBe( + 'UNCAUGHT_SEND_TRANSACTION_ERROR', + ); + expect((result.current.lifecycleStatus.statusData as APIError).error).toBe( + 'Error building send transaction: Uncaught send transaction error', + ); + expect( + (result.current.lifecycleStatus.statusData as APIError).message, + ).toBe('Error: Uncaught send transaction error'); + }); +}); diff --git a/src/wallet/components/wallet-advanced-send/components/SendProvider.tsx b/src/wallet/components/wallet-advanced-send/components/SendProvider.tsx new file mode 100644 index 0000000000..7d4f1d6464 --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/SendProvider.tsx @@ -0,0 +1,293 @@ +import type { + APIError, + PortfolioTokenWithFiatValue, + PriceQuoteToken, +} from '@/api/types'; +import { useExchangeRate } from '@/internal/hooks/useExchangeRate'; +import { useLifecycleStatus } from '@/internal/hooks/useLifecycleStatus'; +import { useSendTransaction } from '@/internal/hooks/useSendTransaction'; +import { useValue } from '@/internal/hooks/useValue'; +import { truncateDecimalPlaces } from '@/internal/utils/truncateDecimalPlaces'; +import type { Call } from '@/transaction/types'; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { formatUnits } from 'viem'; +import { useWalletAdvancedContext } from '../../WalletAdvancedProvider'; +import type { + RecipientAddress, + SendContextType, + SendLifecycleStatus, + SendProviderReact, +} from '../types'; + +const emptyContext = {} as SendContextType; + +const SendContext = createContext(emptyContext); + +export function useSendContext() { + const sendContext = useContext(SendContext); + if (sendContext === emptyContext) { + throw new Error('useSendContext must be used within a SendProvider'); + } + return sendContext; +} + +export function SendProvider({ children }: SendProviderReact) { + const [isInitialized, setIsInitialized] = useState(false); + + // state for ETH balance + const [ethBalance, setEthBalance] = useState(0); + + // state for recipient address selection + const [selectedRecipientAddress, setSelectedRecipientAddress] = + useState({ + display: '', + value: null, + }); + + // state for token selection + const [selectedToken, setSelectedToken] = + useState(null); + const [selectedInputType, setSelectedInputType] = useState<'fiat' | 'crypto'>( + 'crypto', + ); + const [fiatAmount, setFiatAmount] = useState(null); + const [cryptoAmount, setCryptoAmount] = useState(null); + const [exchangeRate, setExchangeRate] = useState(0); + const [exchangeRateLoading, setExchangeRateLoading] = + useState(false); + + // state for transaction data + const [callData, setCallData] = useState(null); + + // lifecycle status + const [lifecycleStatus, updateLifecycleStatus] = + useLifecycleStatus({ + statusName: 'init', + statusData: { + isMissingRequiredField: true, + }, + }); + + // fetch & set ETH balance + const { tokenBalances } = useWalletAdvancedContext(); + useEffect(() => { + const ethBalance = tokenBalances?.find((token) => token.address === ''); + if (ethBalance && ethBalance.cryptoBalance > 0) { + setEthBalance( + Number( + formatUnits(BigInt(ethBalance.cryptoBalance), ethBalance.decimals), + ), + ); + updateLifecycleStatus({ + statusName: 'selectingAddress', + statusData: { + isMissingRequiredField: true, + }, + }); + } else { + updateLifecycleStatus({ + statusName: 'fundingWallet', + statusData: { + isMissingRequiredField: true, + }, + }); + } + setIsInitialized(true); + }, [tokenBalances, updateLifecycleStatus]); + + // fetch & set exchange rate + useEffect(() => { + if (!selectedToken) { + return; + } + + const tokenSymbol = selectedToken.symbol; + const tokenAddress = selectedToken.address; + let tokenParam: PriceQuoteToken; + + if (tokenSymbol === 'ETH') { + tokenParam = 'ETH' as const; + } else if (tokenAddress !== '') { + tokenParam = tokenAddress; + } else { + return; + } + + useExchangeRate({ + token: tokenParam, + selectedInputType, + setExchangeRate, + setExchangeRateLoading, + }); + }, [selectedToken, selectedInputType]); + + // handlers + const handleRecipientInputChange = useCallback(() => { + setSelectedRecipientAddress({ + display: '', + value: null, + }); + updateLifecycleStatus({ + statusName: 'selectingAddress', + statusData: { + isMissingRequiredField: true, + }, + }); + }, [updateLifecycleStatus]); + + const handleAddressSelection = useCallback( + async (selection: RecipientAddress) => { + setSelectedRecipientAddress(selection); + updateLifecycleStatus({ + statusName: 'selectingToken', + statusData: { + isMissingRequiredField: true, + }, + }); + }, + [updateLifecycleStatus], + ); + + const handleTokenSelection = useCallback( + (token: PortfolioTokenWithFiatValue) => { + setSelectedToken(token); + updateLifecycleStatus({ + statusName: 'amountChange', + statusData: { + isMissingRequiredField: true, + sufficientBalance: false, + }, + }); + }, + [updateLifecycleStatus], + ); + + const handleResetTokenSelection = useCallback(() => { + setSelectedToken(null); + setFiatAmount(null); + setCryptoAmount(null); + setExchangeRate(0); + updateLifecycleStatus({ + statusName: 'selectingToken', + statusData: { + isMissingRequiredField: true, + }, + }); + }, [updateLifecycleStatus]); + + const handleFiatAmountChange = useCallback( + (value: string) => { + setFiatAmount(value); + updateLifecycleStatus({ + statusName: 'amountChange', + statusData: { + isMissingRequiredField: true, + sufficientBalance: + Number(value) <= Number(selectedToken?.fiatBalance), + }, + }); + }, + [updateLifecycleStatus, selectedToken], + ); + + const handleCryptoAmountChange = useCallback( + (value: string) => { + const truncatedValue = truncateDecimalPlaces(value, 8); + setCryptoAmount(truncatedValue); + updateLifecycleStatus({ + statusName: 'amountChange', + statusData: { + isMissingRequiredField: true, + sufficientBalance: + Number(value) <= + Number( + formatUnits( + BigInt(selectedToken?.cryptoBalance ?? 0), + selectedToken?.decimals ?? 0, + ), + ), + }, + }); + }, + [updateLifecycleStatus, selectedToken], + ); + + const handleTransactionError = useCallback( + (error: APIError) => { + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: error.code, + error: `Error building send transaction: ${error.error}`, + message: error.message, + }, + }); + }, + [updateLifecycleStatus], + ); + + const fetchTransactionData = useCallback(() => { + if (!selectedRecipientAddress.value || !selectedToken || !cryptoAmount) { + return; + } + + try { + setCallData(null); + const calls = useSendTransaction({ + recipientAddress: selectedRecipientAddress.value, + token: selectedToken, + amount: cryptoAmount, + }); + if ('error' in calls) { + handleTransactionError(calls); + } else { + setCallData(calls); + } + } catch (error) { + handleTransactionError({ + code: 'UNCAUGHT_SEND_TRANSACTION_ERROR', + error: 'Uncaught send transaction error', + message: String(error), + }); + } + }, [ + selectedRecipientAddress, + selectedToken, + cryptoAmount, + handleTransactionError, + ]); + + useEffect(() => { + fetchTransactionData(); + }, [fetchTransactionData]); + + const value = useValue({ + isInitialized, + lifecycleStatus, + updateLifecycleStatus, + ethBalance, + selectedRecipientAddress, + handleAddressSelection, + selectedToken, + handleRecipientInputChange, + handleTokenSelection, + handleResetTokenSelection, + fiatAmount, + handleFiatAmountChange, + cryptoAmount, + handleCryptoAmountChange, + exchangeRate, + exchangeRateLoading, + selectedInputType, + setSelectedInputType, + callData, + }); + + return {children}; +} diff --git a/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.test.tsx b/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.test.tsx index 8358529ee2..85a72061b3 100644 --- a/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.test.tsx +++ b/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.test.tsx @@ -2,6 +2,7 @@ import type { PortfolioTokenWithFiatValue } from '@/api/types'; import { fireEvent, render, screen } from '@testing-library/react'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { useWalletAdvancedContext } from '../../WalletAdvancedProvider'; +import { useSendContext } from './SendProvider'; import { SendTokenSelector } from './SendTokenSelector'; // Mock the context hook @@ -9,6 +10,10 @@ vi.mock('../../WalletAdvancedProvider', () => ({ useWalletAdvancedContext: vi.fn(), })); +vi.mock('./SendProvider', () => ({ + useSendContext: vi.fn(), +})); + const mockTokenBalances: PortfolioTokenWithFiatValue[] = [ { address: '0x1230000000000000000000000000000000000000', @@ -32,25 +37,26 @@ const mockTokenBalances: PortfolioTokenWithFiatValue[] = [ }, ]; -describe('SendTokenSelector', () => { - const defaultProps = { - selectedToken: null, - handleTokenSelection: vi.fn(), - handleResetTokenSelection: vi.fn(), - setSelectedInputType: vi.fn(), - handleCryptoAmountChange: vi.fn(), - handleFiatAmountChange: vi.fn(), - }; +const defaultContext = { + selectedToken: null, + handleTokenSelection: vi.fn(), + handleResetTokenSelection: vi.fn(), + setSelectedInputType: vi.fn(), + handleCryptoAmountChange: vi.fn(), + handleFiatAmountChange: vi.fn(), +}; +describe('SendTokenSelector', () => { beforeEach(() => { vi.clearAllMocks(); (useWalletAdvancedContext as Mock).mockReturnValue({ tokenBalances: mockTokenBalances, }); + (useSendContext as Mock).mockReturnValue(defaultContext); }); it('renders token selection list when no token is selected', () => { - render(); + render(); expect(screen.getByText('Select a token')).toBeInTheDocument(); expect(screen.getAllByRole('button')).toHaveLength( @@ -59,54 +65,54 @@ describe('SendTokenSelector', () => { }); it('calls handleTokenSelection when a token is clicked from the list', () => { - render(); + render(); fireEvent.click(screen.getAllByTestId('ockTokenBalanceButton')[0]); - expect(defaultProps.handleTokenSelection).toHaveBeenCalledWith( + expect(defaultContext.handleTokenSelection).toHaveBeenCalledWith( mockTokenBalances[0], ); }); it('renders selected token with max button when token is selected', () => { - render( - , - ); + (useSendContext as Mock).mockReturnValue({ + ...defaultContext, + selectedToken: mockTokenBalances[0], + }); + + render(); expect(screen.getByText('Test Token')).toBeInTheDocument(); expect(screen.getByText(/0\.000 TEST available/)).toBeInTheDocument(); }); it('handles max button click correctly', () => { - render( - , - ); + (useSendContext as Mock).mockReturnValue({ + ...defaultContext, + selectedToken: mockTokenBalances[0], + }); + + render(); const maxButton = screen.getByRole('button', { name: 'Use max' }); fireEvent.click(maxButton); - expect(defaultProps.setSelectedInputType).toHaveBeenCalledWith('crypto'); - expect(defaultProps.handleFiatAmountChange).toHaveBeenCalledWith('100'); - expect(defaultProps.handleCryptoAmountChange).toHaveBeenCalledWith( + expect(defaultContext.setSelectedInputType).toHaveBeenCalledWith('crypto'); + expect(defaultContext.handleFiatAmountChange).toHaveBeenCalledWith('100'); + expect(defaultContext.handleCryptoAmountChange).toHaveBeenCalledWith( '0.000000001', ); }); it('calls handleResetTokenSelection when selected token is clicked', () => { - render( - , - ); + (useSendContext as Mock).mockReturnValue({ + ...defaultContext, + selectedToken: mockTokenBalances[0], + }); + + render(); fireEvent.click(screen.getByTestId('ockTokenBalanceButton')); - expect(defaultProps.handleResetTokenSelection).toHaveBeenCalled(); + expect(defaultContext.handleResetTokenSelection).toHaveBeenCalled(); }); it('handles empty tokenBalances gracefully', () => { @@ -114,7 +120,7 @@ describe('SendTokenSelector', () => { tokenBalances: [], }); - render(); + render(); expect(screen.getByText('Select a token')).toBeInTheDocument(); }); @@ -124,19 +130,18 @@ describe('SendTokenSelector', () => { }; const { rerender } = render( - , + , ); const buttons = screen.getAllByTestId('ockTokenBalanceButton'); expect(buttons[0]).toHaveClass(customClassNames.container); expect(buttons[1]).toHaveClass(customClassNames.container); - rerender( - , - ); + (useSendContext as Mock).mockReturnValue({ + ...defaultContext, + selectedToken: mockTokenBalances[0], + }); + + rerender(); const button = screen.getByTestId('ockTokenBalanceButton'); expect(button).toHaveClass(customClassNames.container); }); diff --git a/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.tsx b/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.tsx index b2e4e5c69c..2b00cd0d74 100644 --- a/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.tsx +++ b/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.tsx @@ -4,18 +4,28 @@ import { border, cn, color, pressable, text } from '@/styles/theme'; import { TokenBalance } from '@/token'; import { formatUnits } from 'viem'; import { useWalletAdvancedContext } from '../../WalletAdvancedProvider'; -import type { SendTokenSelectorProps } from '../types'; +import { useSendContext } from './SendProvider'; -export function SendTokenSelector({ - selectedToken, - handleTokenSelection, - handleResetTokenSelection, - setSelectedInputType, - handleCryptoAmountChange, - handleFiatAmountChange, - classNames, -}: SendTokenSelectorProps) { +type SendTokenSelectorProps = { + classNames?: { + container?: string; + tokenName?: string; + tokenValue?: string; + fiatValue?: string; + action?: string; + }; +}; + +export function SendTokenSelector({ classNames }: SendTokenSelectorProps) { const { tokenBalances } = useWalletAdvancedContext(); + const { + selectedToken, + handleTokenSelection, + handleResetTokenSelection, + setSelectedInputType, + handleCryptoAmountChange, + handleFiatAmountChange, + } = useSendContext(); if (!selectedToken) { return ( diff --git a/src/wallet/components/wallet-advanced-send/constants.ts b/src/wallet/components/wallet-advanced-send/constants.ts new file mode 100644 index 0000000000..b784b5942d --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/constants.ts @@ -0,0 +1 @@ +export const ETH_REQUIRED_FOR_SEND = 0.000001; diff --git a/src/wallet/components/wallet-advanced-send/types.ts b/src/wallet/components/wallet-advanced-send/types.ts index 86796fb1e5..c7a486f661 100644 --- a/src/wallet/components/wallet-advanced-send/types.ts +++ b/src/wallet/components/wallet-advanced-send/types.ts @@ -1,9 +1,14 @@ import type { Dispatch, ReactNode, SetStateAction } from 'react'; -import type { Address, TransactionReceipt } from 'viem'; +import type { Address, Chain, TransactionReceipt } from 'viem'; import type { APIError, PortfolioTokenWithFiatValue } from '../../../api/types'; import type { LifecycleStatusUpdate } from '../../../internal/types'; import type { Call } from '../../../transaction/types'; +export type SendReact = { + children?: ReactNode; + className?: string; +}; + export type SendProviderReact = { children: ReactNode; }; @@ -21,7 +26,7 @@ export type SendContextType = { // Sender Context /** The balance of the sender's ETH wallet */ - ethBalance: number | undefined; + ethBalance: number; // Recipient Address Context /** The selected recipient address */ @@ -122,47 +127,27 @@ export type SendLifecycleStatus = statusData: APIError; }; -export type SendAmountInputProps = { - className?: string; - textClassName?: string; -} & Pick< - SendContextType, - | 'selectedToken' - | 'cryptoAmount' - | 'handleCryptoAmountChange' - | 'fiatAmount' - | 'handleFiatAmountChange' - | 'selectedInputType' - | 'setSelectedInputType' - | 'exchangeRate' - | 'exchangeRateLoading' ->; - -export type SendFundingWalletProps = { - onError?: () => void; - onStatus?: () => void; - onSuccess?: () => void; +export type SendAddressInputProps = { + selectedRecipientAddress: RecipientAddress; + recipientInput: string; + setRecipientInput: Dispatch>; + setValidatedInput: Dispatch>; + handleRecipientInputChange: () => void; classNames?: { container?: string; - subtitle?: string; - fundCard?: string; + label?: string; + input?: string; }; }; -export type SendTokenSelectorProps = { +export type SendAddressSelectorProps = { + address: Address | null; + senderChain: Chain | null | undefined; + handleClick: () => Promise; classNames?: { container?: string; - tokenName?: string; - tokenValue?: string; - fiatValue?: string; - action?: string; + avatar?: string; + name?: string; + address?: string; }; -} & Pick< - SendContextType, - | 'selectedToken' - | 'handleTokenSelection' - | 'handleResetTokenSelection' - | 'setSelectedInputType' - | 'handleCryptoAmountChange' - | 'handleFiatAmountChange' ->; +}; diff --git a/src/wallet/components/wallet-advanced-send/utils/getDefaultSendButtonLabel.test.ts b/src/wallet/components/wallet-advanced-send/utils/getDefaultSendButtonLabel.test.ts deleted file mode 100644 index e108d75322..0000000000 --- a/src/wallet/components/wallet-advanced-send/utils/getDefaultSendButtonLabel.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { PortfolioTokenWithFiatValue } from '@/api/types'; -import { describe, expect, it } from 'vitest'; -import { getDefaultSendButtonLabel } from './getDefaultSendButtonLabel'; - -describe('getDefaultSendButtonLabel', () => { - const mockToken = { - address: '0x1230000000000000000000000000000000000000', - symbol: 'TEST', - name: 'Test Token', - decimals: 18, - cryptoBalance: 1000000000000000, - fiatBalance: 100, - image: 'test.png', - chainId: 8453, - } as PortfolioTokenWithFiatValue; - - it('returns "Input amount" when cryptoAmount is null', () => { - expect(getDefaultSendButtonLabel(null, mockToken)).toBe('Input amount'); - }); - - it('returns "Input amount" when cryptoAmount is empty string', () => { - expect(getDefaultSendButtonLabel('', mockToken)).toBe('Input amount'); - }); - - it('returns "Select token" when token is null', () => { - expect(getDefaultSendButtonLabel('1.0', null)).toBe('Select token'); - }); - - it('returns "Insufficient balance" when amount exceeds balance', () => { - expect(getDefaultSendButtonLabel('2.0', mockToken)).toBe( - 'Insufficient balance', - ); - }); - - it('returns "Continue" when amount is valid and within balance', () => { - expect(getDefaultSendButtonLabel('0.0001', mockToken)).toBe('Continue'); - }); - - it('returns "Continue" when amount equals balance exactly', () => { - expect(getDefaultSendButtonLabel('0.001', mockToken)).toBe('Continue'); - }); -}); diff --git a/src/wallet/components/wallet-advanced-send/utils/getDefaultSendButtonLabel.ts b/src/wallet/components/wallet-advanced-send/utils/getDefaultSendButtonLabel.ts deleted file mode 100644 index 883b20bf81..0000000000 --- a/src/wallet/components/wallet-advanced-send/utils/getDefaultSendButtonLabel.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { PortfolioTokenWithFiatValue } from '@/api/types'; -import { parseUnits } from 'viem'; - -export function getDefaultSendButtonLabel( - cryptoAmount: string | null, - selectedToken: PortfolioTokenWithFiatValue | null, -) { - if (!cryptoAmount) { - return 'Input amount'; - } - - if (!selectedToken) { - return 'Select token'; - } - - if ( - parseUnits(cryptoAmount, selectedToken.decimals) > - selectedToken.cryptoBalance - ) { - return 'Insufficient balance'; - } - - return 'Continue'; -} diff --git a/src/wallet/types.ts b/src/wallet/types.ts index c46b1a3af1..85e8ea8cbb 100644 --- a/src/wallet/types.ts +++ b/src/wallet/types.ts @@ -214,7 +214,7 @@ export type WalletAdvancedReact = { }; }; -export type WalletAdvancedFeature = 'qr' | 'swap'; +export type WalletAdvancedFeature = 'qr' | 'swap' | 'send'; /** * Note: exported as public Type