From da516e67c704b5c22738f1310543294f2d67f236 Mon Sep 17 00:00:00 2001 From: Brendan Forster Date: Tue, 4 Mar 2025 11:20:35 -0800 Subject: [PATCH 01/10] fix error message and types --- src/api/getPriceQuote.test.ts | 2 +- src/api/getPriceQuote.ts | 4 ++-- src/api/types.ts | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/api/getPriceQuote.test.ts b/src/api/getPriceQuote.test.ts index 6df5ece562..f78157eee6 100644 --- a/src/api/getPriceQuote.test.ts +++ b/src/api/getPriceQuote.test.ts @@ -67,7 +67,7 @@ describe('getPriceQuote', () => { expect(result).toEqual({ code: 'INVALID_INPUT', error: 'Invalid input: tokens must be an array of at least one token', - message: '', + message: 'Tokens must be an array of at least one token', }); }); diff --git a/src/api/getPriceQuote.ts b/src/api/getPriceQuote.ts index 6f0ce02878..e22c26d3ac 100644 --- a/src/api/getPriceQuote.ts +++ b/src/api/getPriceQuote.ts @@ -29,7 +29,7 @@ export async function getPriceQuote( ); if (res.error) { return { - code: `${res.error.code}`, + code: String(res.error.code), error: 'Error fetching price quote', message: res.error.message, }; @@ -51,7 +51,7 @@ function validateGetPriceQuoteParams(params: GetPriceQuoteParams) { return { code: 'INVALID_INPUT', error: 'Invalid input: tokens must be an array of at least one token', - message: '', + message: 'Tokens must be an array of at least one token', }; } diff --git a/src/api/types.ts b/src/api/types.ts index 651b97b800..1d02129ce1 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -397,15 +397,15 @@ export type GetPriceQuoteParams = { type PriceQuote = { /** The name of the token */ - name: string | ''; + name: string; /** The symbol of the token */ - symbol: string | ''; + symbol: string; /** The contract address of the token */ contractAddress: Address | ''; /** The price of the token */ - price: string | ''; + price: string; /** The timestamp of the price quote */ - timestamp: number | 0; + timestamp: number; }; /** From 30050bb16f9195eeb974a2bf0cb9c6aaa0bedb44 Mon Sep 17 00:00:00 2001 From: Brendan Forster Date: Mon, 3 Mar 2025 14:54:22 -0800 Subject: [PATCH 02/10] wallet updates --- .../WalletAdvancedTransactionActions.test.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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', () => { From f4d2a2eff2ae35c0602431164dcd447aeb658b19 Mon Sep 17 00:00:00 2001 From: Brendan Forster Date: Mon, 3 Mar 2025 15:05:10 -0800 Subject: [PATCH 03/10] fix test --- .../WalletAdvancedTransactionActions.test.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/wallet/components/WalletAdvancedTransactionActions.test.tsx b/src/wallet/components/WalletAdvancedTransactionActions.test.tsx index 896cddb029..7c59ea0a2a 100644 --- a/src/wallet/components/WalletAdvancedTransactionActions.test.tsx +++ b/src/wallet/components/WalletAdvancedTransactionActions.test.tsx @@ -142,19 +142,16 @@ describe('WalletAdvancedTransactionActons', () => { expect(window.open).not.toHaveBeenCalled(); }); - it('sets activeFeature to send when the send button is clicked', () => { - mockUseWalletAdvancedContext.mockReturnValue( - defaultMockUseWalletAdvancedContext, - ); - + it('opens the send page when the send button is clicked', () => { render(); const sendButton = screen.getByRole('button', { name: 'Send' }); fireEvent.click(sendButton); - expect( - defaultMockUseWalletAdvancedContext.setActiveFeature, - ).toHaveBeenCalledWith('send'); + expect(window.open).toHaveBeenCalledWith( + 'https://wallet.coinbase.com', + '_blank', + ); }); it('sets activeFeature to swap when the swap button is clicked', () => { From f1d6b879105657f191fa0f1e8ee00f13c965a9ac Mon Sep 17 00:00:00 2001 From: Brendan Forster Date: Tue, 4 Mar 2025 11:07:02 -0800 Subject: [PATCH 04/10] initial send scaffold --- .../components/Send.test.tsx | 176 +++++ .../wallet-advanced-send/components/Send.tsx | 72 ++ .../components/SendHeader.test.tsx | 157 ++++ .../components/SendHeader.tsx | 82 +++ .../components/SendProvider.test.tsx | 670 ++++++++++++++++++ .../components/SendProvider.tsx | 293 ++++++++ 6 files changed, 1450 insertions(+) create mode 100644 src/wallet/components/wallet-advanced-send/components/Send.test.tsx create mode 100644 src/wallet/components/wallet-advanced-send/components/Send.tsx create mode 100644 src/wallet/components/wallet-advanced-send/components/SendHeader.test.tsx create mode 100644 src/wallet/components/wallet-advanced-send/components/SendHeader.tsx create mode 100644 src/wallet/components/wallet-advanced-send/components/SendProvider.test.tsx create mode 100644 src/wallet/components/wallet-advanced-send/components/SendProvider.tsx 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..af7acab983 --- /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..a189b64bbf --- /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/SendHeader.test.tsx b/src/wallet/components/wallet-advanced-send/components/SendHeader.test.tsx new file mode 100644 index 0000000000..ad59da5fc9 --- /dev/null +++ b/src/wallet/components/wallet-advanced-send/components/SendHeader.test.tsx @@ -0,0 +1,157 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useWalletAdvancedContext } from '../../WalletAdvancedProvider'; +import { SendHeader } from './SendHeader'; +import { useSendContext } from './SendProvider'; + +vi.mock('../../WalletAdvancedProvider', () => ({ + 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}; +} From 40f4f2b7b2ac094a3a198a672d1a98ae38ff29ab Mon Sep 17 00:00:00 2001 From: Brendan Forster Date: Tue, 4 Mar 2025 11:07:23 -0800 Subject: [PATCH 05/10] fix types --- .../components/SendFundWallet.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 (
Date: Tue, 4 Mar 2025 11:10:47 -0800 Subject: [PATCH 06/10] add types, constants --- .../wallet-advanced-send/constants.ts | 1 + .../components/wallet-advanced-send/types.ts | 61 +++++++------------ 2 files changed, 24 insertions(+), 38 deletions(-) create mode 100644 src/wallet/components/wallet-advanced-send/constants.ts 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' ->; +}; From 6873ce94b42545c27a44b9af55905ff1a4e85f23 Mon Sep 17 00:00:00 2001 From: Brendan Forster Date: Tue, 4 Mar 2025 11:33:09 -0800 Subject: [PATCH 07/10] wallet scaffolding --- .../components/WalletAdvancedContent.test.tsx | 36 +++++++++++++++++++ .../components/WalletAdvancedContent.tsx | 9 +++++ .../WalletAdvancedTransactionActions.test.tsx | 13 ++++--- .../WalletAdvancedTransactionActions.tsx | 4 +-- src/wallet/types.ts | 2 +- 5 files changed, 56 insertions(+), 8 deletions(-) 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/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 From ca1483e41cb2f9b71bf6344c01af6518718db7e5 Mon Sep 17 00:00:00 2001 From: Brendan Forster Date: Tue, 4 Mar 2025 11:44:05 -0800 Subject: [PATCH 08/10] initial send scaffolding --- .../nextjs-app-router/onchainkit/package.json | 2 +- .../components/Send.test.tsx | 142 +++++++++--------- .../wallet-advanced-send/components/Send.tsx | 15 +- .../components/SendAmountInput.test.tsx | 106 ++++++------- .../components/SendAmountInput.tsx | 38 +++-- .../SendAmountInputTypeSwitch.test.tsx | 116 ++++++++------ .../components/SendAmountInputTypeSwitch.tsx | 41 +++-- .../components/SendTokenSelector.test.tsx | 91 +++++------ .../components/SendTokenSelector.tsx | 30 ++-- 9 files changed, 300 insertions(+), 281 deletions(-) diff --git a/playground/nextjs-app-router/onchainkit/package.json b/playground/nextjs-app-router/onchainkit/package.json index 0679f21097..484b7f296f 100644 --- a/playground/nextjs-app-router/onchainkit/package.json +++ b/playground/nextjs-app-router/onchainkit/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/onchainkit", - "version": "0.37.4", + "version": "0.37.5", "type": "module", "repository": "https://github.com/coinbase/onchainkit.git", "license": "MIT", diff --git a/src/wallet/components/wallet-advanced-send/components/Send.test.tsx b/src/wallet/components/wallet-advanced-send/components/Send.test.tsx index af7acab983..6295fb551f 100644 --- a/src/wallet/components/wallet-advanced-send/components/Send.test.tsx +++ b/src/wallet/components/wallet-advanced-send/components/Send.test.tsx @@ -1,8 +1,8 @@ import { Skeleton } from '@/internal/components/Skeleton'; import { render, screen } from '@testing-library/react'; -import type { Address } from 'viem'; +// import type { Address } from 'viem'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ETH_REQUIRED_FOR_SEND } from '../constants'; +// import { ETH_REQUIRED_FOR_SEND } from '../constants'; import type { SendContextType } from '../types'; import { Send } from './Send'; // import { SendAddressSelection } from './SendAddressSelection'; @@ -16,9 +16,9 @@ import { SendProvider, useSendContext } from './SendProvider'; // Mock all dependencies vi.mock('@/internal/components/Skeleton'); vi.mock('@/internal/hooks/useTheme'); -vi.mock('./SendAddressSelection'); -vi.mock('./SendAmountInput'); -vi.mock('./SendButton'); +// vi.mock('./SendAddressSelection'); +// vi.mock('./SendAmountInput'); +// vi.mock('./SendButton'); vi.mock('./SendFundWallet'); vi.mock('./SendHeader'); vi.mock('./SendProvider', () => ({ @@ -29,17 +29,17 @@ vi.mock('./SendProvider', () => ({ })); 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, -}; +// 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(() => { @@ -115,62 +115,62 @@ describe('Send', () => { expect(SendHeader).toHaveBeenCalled(); expect(SendFundWallet).toHaveBeenCalled(); - expect(SendAddressSelection).not.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(); - }); + // 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 index a189b64bbf..640028c3ea 100644 --- a/src/wallet/components/wallet-advanced-send/components/Send.tsx +++ b/src/wallet/components/wallet-advanced-send/components/Send.tsx @@ -36,11 +36,13 @@ export function Send({ } function SendDefaultChildren() { - const { ethBalance, isInitialized, selectedRecipientAddress, selectedToken } = - useSendContext(); + // const { ethBalance, isInitialized, selectedRecipientAddress, selectedToken } = + const { ethBalance, isInitialized } = useSendContext(); const walletHasEth = ethBalance > ETH_REQUIRED_FOR_SEND; + console.log({ ethBalance, isInitialized, walletHasEth }); + if (!isInitialized) { return ; } @@ -50,19 +52,20 @@ function SendDefaultChildren() { {walletHasEth ? (
-
+
This wallet has ETH. Test by changing to a wallet with no ETH.
+ {/*
{selectedRecipientAddress.value && !selectedToken && ( )} -
- {selectedRecipientAddress.value && selectedToken && ( +
*/} + {/* {selectedRecipientAddress.value && selectedToken && ( <> - )} + )} */}
) : ( 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/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 ( From 70feb5aa9bbceeb2929bf29777a3b38abcc524f1 Mon Sep 17 00:00:00 2001 From: Brendan Forster Date: Tue, 4 Mar 2025 11:47:13 -0800 Subject: [PATCH 09/10] fix lints --- .../components/wallet-advanced-send/components/Send.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/wallet/components/wallet-advanced-send/components/Send.tsx b/src/wallet/components/wallet-advanced-send/components/Send.tsx index 640028c3ea..6853a6b3d2 100644 --- a/src/wallet/components/wallet-advanced-send/components/Send.tsx +++ b/src/wallet/components/wallet-advanced-send/components/Send.tsx @@ -52,7 +52,9 @@ function SendDefaultChildren() { {walletHasEth ? (
-
This wallet has ETH. Test by changing to a wallet with no ETH.
+
+ This wallet has ETH. Test by changing to a wallet with no ETH. +
{/*
{selectedRecipientAddress.value && !selectedToken && ( From 6745cd766e98cf17ad5db59b27a8e343f82a7b8b Mon Sep 17 00:00:00 2001 From: Brendan Forster Date: Tue, 4 Mar 2025 12:25:11 -0800 Subject: [PATCH 10/10] add placeholder test --- .../wallet-advanced-send/components/Send.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/wallet/components/wallet-advanced-send/components/Send.test.tsx b/src/wallet/components/wallet-advanced-send/components/Send.test.tsx index 6295fb551f..d4a429a7a6 100644 --- a/src/wallet/components/wallet-advanced-send/components/Send.test.tsx +++ b/src/wallet/components/wallet-advanced-send/components/Send.test.tsx @@ -118,6 +118,18 @@ describe('Send', () => { // expect(SendAddressSelection).not.toHaveBeenCalled(); }); + it('renders a placeholder when wallet has sufficient ETH', () => { + vi.mocked(useSendContext).mockReturnValue({ + isInitialized: true, + ethBalance: 0, + } as SendContextType); + + render(); + + expect(SendHeader).toHaveBeenCalled(); + expect(screen.getByText('This wallet has ETH.')).toBeInTheDocument(); + }); + // it('renders SendAddressSelection when wallet has sufficient ETH', () => { // vi.mocked(useSendContext).mockReturnValue({ // isInitialized: true,