diff --git a/src/components/AssetBadge.tsx b/src/components/AssetBadge.tsx index 176e64830..e48013c9c 100644 --- a/src/components/AssetBadge.tsx +++ b/src/components/AssetBadge.tsx @@ -48,7 +48,8 @@ function AssetBadge(props: Props) { > ); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 7b06b5cf7..28c2fdee1 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -7,7 +7,7 @@ type Props = { text: string; align?: Alignment; size?: number; - testId?: string; + weight?: number; }; function Header(props: Props) { @@ -19,18 +19,15 @@ function Header(props: Props) { width: '100%', textAlign: props.align || 'center', fontFamily: theme.typography.fontFamily, + fontWeight: props.weight || 400, [theme.breakpoints.down('sm')]: { fontSize: '24px', }, }), - [theme, props.align, props.size], + [theme, props.align, props.size, props.weight], ); - return ( - - {props.text} - - ); + return {props.text}; } export default Header; diff --git a/src/components/PageHeader.tsx b/src/components/PageHeader.tsx index 00239fb8b..44c462e4c 100644 --- a/src/components/PageHeader.tsx +++ b/src/components/PageHeader.tsx @@ -76,7 +76,7 @@ function PageHeader({ {back && ( )} -
+
{description && {description}} diff --git a/src/config/constants.ts b/src/config/constants.ts index cba58530c..b2b089edc 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -14,12 +14,12 @@ export const CHAIN_ORDER: Chain[] = [ 'Arbitrum', 'Base', 'Sui', + 'HyperEVM', 'Bsc', - 'Optimism', + 'Avalanche', 'Unichain', - 'Fantom', + 'Optimism', 'Polygon', - 'Avalanche', 'Celo', 'Moonbeam', 'Klaytn', @@ -28,6 +28,6 @@ export const CHAIN_ORDER: Chain[] = [ 'Berachain', 'Mezo', 'Fogo', - 'HyperEVM', 'HyperCore', + 'Fantom', ]; diff --git a/src/icons/ArrowLeft.tsx b/src/icons/ArrowLeft.tsx new file mode 100644 index 000000000..37a15e3a8 --- /dev/null +++ b/src/icons/ArrowLeft.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { createSvgIcon } from '@mui/material'; + +const ArrowLeftIcon = createSvgIcon( + + + , + 'ArrowRight', +); + +export default ArrowLeftIcon; diff --git a/src/icons/Bridge.tsx b/src/icons/Bridge.tsx new file mode 100644 index 000000000..4228d92cf --- /dev/null +++ b/src/icons/Bridge.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { createSvgIcon } from '@mui/material'; + +const BridgeIcon = createSvgIcon( + + + , + 'Alert', +); + +export default BridgeIcon; diff --git a/src/icons/TokenIcons.tsx b/src/icons/TokenIcons.tsx index 7b43e3a6d..337a4de2e 100644 --- a/src/icons/TokenIcons.tsx +++ b/src/icons/TokenIcons.tsx @@ -108,6 +108,7 @@ function isBuiltinTokenIcon(icon?: TokenIcon | string): icon is TokenIcon { type Props = { icon?: TokenIcon | string; style?: React.CSSProperties; + containerStyle?: React.CSSProperties; }; function EmptyIcon(props: { style: React.CSSProperties }) { @@ -129,7 +130,7 @@ function TokenIconComponent(props: Props) { const styles = useMemo( () => ({ container: { - ...(props.style || { width: '36px', height: '36px' }), + ...(props.containerStyle || { width: '36px', height: '36px' }), ...CENTER, }, iconImage: { @@ -137,7 +138,7 @@ function TokenIconComponent(props: Props) { borderRadius: '50px', }, }), - [props.style], // Recompute styles only when style prop changes + [props.containerStyle, props.style], // Recompute styles only when style prop changes ); if (isBuiltinTokenIcon(props.icon) && iconMap[props.icon]) { diff --git a/src/views/v3/Bridge/AssetPicker/ChainList.test.tsx b/src/views/v3/Bridge/AssetPicker/ChainList.test.tsx new file mode 100644 index 000000000..7dab9b1d0 --- /dev/null +++ b/src/views/v3/Bridge/AssetPicker/ChainList.test.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material'; + +import ChainList from './ChainList'; +import { dark } from 'theme'; + +const theme = createTheme({ + palette: dark as any, +}); + +const mockChainConfigs = [ + { + key: 'Ethereum', + displayName: 'Ethereum', + sdkName: 'Ethereum' as const, + icon: 'Ethereum' as const, + explorerUrl: 'https://etherscan.io', + explorerName: 'Etherscan', + }, + { + key: 'Solana', + displayName: 'Solana', + sdkName: 'Solana' as const, + icon: 'Solana' as const, + explorerUrl: 'https://solscan.io', + explorerName: 'Solscan', + }, + { + key: 'Arbitrum', + displayName: 'Arbitrum', + sdkName: 'Arbitrum' as const, + icon: 'Arbitrum' as const, + explorerUrl: 'https://arbiscan.io', + explorerName: 'Arbiscan', + }, +]; + +const mockWallet = { + type: 'Evm' as const, + address: '0x123', + currentAddress: '0x123', + error: '', + name: 'Test Wallet', + sending: { address: '0x123' }, + receiving: { address: '0x456' }, +}; + +const defaultProps = { + chainList: mockChainConfigs, + selectedChainConfig: undefined, + showSearch: false, + setShowSearch: vi.fn(), + wallet: mockWallet, + onChainSelect: vi.fn(), +}; + +const AppWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('ChainList', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders chain list with chain buttons', () => { + render(, { wrapper: AppWrapper }); + + expect(screen.getByText('Ethereum')).toBeInTheDocument(); + expect(screen.getByText('Solana')).toBeInTheDocument(); + expect(screen.getByText('Arbitrum')).toBeInTheDocument(); + }); + + it('calls onChainSelect when a chain button is clicked', () => { + render(, { wrapper: AppWrapper }); + + const ethereumButton = screen + .getByText('Ethereum') + .closest('div[role="button"]'); + fireEvent.click(ethereumButton!); + + expect(defaultProps.onChainSelect).toHaveBeenCalledWith('Ethereum'); + }); + + it('shows search interface when showSearch is true', () => { + const props = { ...defaultProps, showSearch: true }; + render(, { wrapper: AppWrapper }); + + expect(screen.getByLabelText('Search')).toBeInTheDocument(); + }); +}); diff --git a/src/views/v3/Bridge/AssetPicker/ChainList.tsx b/src/views/v3/Bridge/AssetPicker/ChainList.tsx index 432cd4a8d..c22cb8ca0 100644 --- a/src/views/v3/Bridge/AssetPicker/ChainList.tsx +++ b/src/views/v3/Bridge/AssetPicker/ChainList.tsx @@ -1,22 +1,20 @@ import React, { useMemo, useState } from 'react'; import { useTheme } from '@mui/material/styles'; +import Box from '@mui/material/Box'; import Card from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; -import List from '@mui/material/List'; import ListItemButton from '@mui/material/ListItemButton'; import ListItemIcon from '@mui/material/ListItemIcon'; -import Stack from '@mui/material/Stack'; -import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import ChainIcon from 'icons/ChainIcons'; import PlusIcon from 'icons/Plus'; - -import type { ChainConfig } from 'config/types'; -import type { WalletData } from 'store/wallet'; import SearchableList from 'views/v3/Bridge/AssetPicker/SearchableList'; import type { Chain } from '@wormhole-foundation/sdk'; +import type { ChainConfig } from 'config/types'; +import type { WalletData } from 'store/wallet'; +import { OPACITY } from 'utils/style'; type Props = { chainList?: ChainConfig[]; @@ -27,7 +25,7 @@ type Props = { onChainSelect: (chain: Chain) => void; }; -const SHORT_LIST_SIZE = 5; +const SHORT_LIST_SIZE = 10; // including "other" button function ChainList(props: Props) { const theme = useTheme(); @@ -37,7 +35,7 @@ function ChainList(props: Props) { () => ({ card: { background: theme.palette.input.background, - maxWidth: '420px', + maxWidth: '452px', [theme.breakpoints.down('sm')]: { width: '100vw', }, @@ -52,31 +50,66 @@ function ChainList(props: Props) { }, }, title: { - fontSize: '14px', + fontSize: '24px', + fontWeight: 600, + lineHeight: '32px', marginBottom: '12px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', }, - chainSearch: { + chainSearchList: { maxHeight: '480px', [theme.breakpoints.down('sm')]: { maxHeight: '640px', }, }, + chainSearchItem: { + display: 'flex', + flexDirection: 'row' as const, + padding: '8px', + borderRadius: '8px', + }, chainButton: { display: 'flex', flexDirection: 'column' as const, padding: '8px', + backgroundColor: theme.palette.primary.main + OPACITY[10], border: '1px solid transparent', borderRadius: '8px', + width: '71px', + maxWidth: '71px', + position: 'relative', + overflow: 'hidden', '&.Mui-selected': { border: '1px solid', borderColor: theme.palette.primary.main, }, + '& svg': { + pointerEvents: 'none', + }, + '&:hover': { + backgroundColor: theme.palette.primary.main + OPACITY[10], + border: '1px solid', + borderColor: theme.palette.primary.main, + }, }, - chainItem: { - display: 'flex', - flexDirection: 'row' as const, - padding: '8px', - borderRadius: '8px', + chainTileLabel: { + color: theme.palette.text.secondary, + fontSize: '9px', + fontFamily: 'IBM Plex Mono', + fontWeight: 400, + lineHeight: '12px', + marginTop: '8px', + whiteSpace: 'nowrap', + }, + chainIcon: { + border: '1px solid transparent', + borderRadius: '100%', + width: '24px', + height: '24px', + overflow: 'hidden', + pointerEvents: 'none', }, }), [theme], @@ -102,39 +135,39 @@ function ChainList(props: Props) { if ( selectedChainConfig && selectedChainIndex && - selectedChainIndex >= SHORT_LIST_SIZE + selectedChainIndex >= SHORT_LIST_SIZE - 1 // Subtract 1 to account for "other" button ) { - return [selectedChainConfig, ...allChains.slice(0, SHORT_LIST_SIZE - 1)]; + return [selectedChainConfig, ...allChains.slice(0, SHORT_LIST_SIZE - 2)]; } - return allChains.slice(0, SHORT_LIST_SIZE); + return allChains.slice(0, SHORT_LIST_SIZE - 1); // Leave room for "other" button }, [chainList, selectedChainConfig]); - const showMoreButton = (chainList?.length ?? 0) > SHORT_LIST_SIZE; + const showMoreButton = (chainList?.length ?? 0) > SHORT_LIST_SIZE - 1; const shortList = useMemo(() => { return ( - + {topChains.map((chain: ChainConfig) => ( - - onChainSelect(chain.sdkName)} - > - - - {chain.symbol} - - - + onChainSelect(chain.sdkName)} + > + + + + {chain.sdkName} + ))} {showMoreButton ? ( @@ -144,33 +177,30 @@ function ChainList(props: Props) { setShowSearch(true); }} > - - - other - + + + + other ) : null} - + ); }, [ + topChains, + showMoreButton, styles.chainButton, - onChainSelect, + styles.chainIcon, + styles.chainTileLabel, selectedChainConfig?.sdkName, + onChainSelect, setShowSearch, - showMoreButton, - topChains, ]); const searchList = useMemo( () => ( searchPlaceholder="Search for a chain" - sx={styles.chainSearch} + sx={styles.chainSearchList} items={chainList ?? []} searchQuery={chainSearchQuery} onQueryChange={setChainSearchQuery} @@ -182,14 +212,16 @@ function ChainList(props: Props) { { onChainSelect(chain.sdkName); setShowSearch(false); }} > - + + + {chain.displayName} @@ -199,10 +231,11 @@ function ChainList(props: Props) { /> ), [ + styles.chainSearchList, + styles.chainSearchItem, + styles.chainIcon, chainList, chainSearchQuery, - styles.chainItem, - styles.chainSearch, onChainSelect, setShowSearch, ], @@ -215,8 +248,14 @@ function ChainList(props: Props) { return ( - - Select a network + + Choose network {showSearch ? searchList : shortList} diff --git a/src/views/v3/Bridge/AssetPicker/PickerBottomSheet.tsx b/src/views/v3/Bridge/AssetPicker/PickerBottomSheet.tsx index b6b62a96b..c9d75aa3e 100644 --- a/src/views/v3/Bridge/AssetPicker/PickerBottomSheet.tsx +++ b/src/views/v3/Bridge/AssetPicker/PickerBottomSheet.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import React from 'react'; import { useTheme } from '@mui/material'; import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; @@ -11,6 +11,7 @@ import type { Token } from 'config/tokens'; import type { Balances } from 'utils/wallet/types'; import ChainList from 'views/v3/Bridge/AssetPicker/ChainList'; import TokenList from 'views/v3/Bridge/AssetPicker/TokenList'; +import PickerHeader from './PickerHeader'; interface AssetPickerDrawerProps { isDrawerOpen: boolean; @@ -92,6 +93,11 @@ function AssetPickerDrawer({ onClose={() => setIsDrawerOpen(false)} > {drawerHandle} + setIsDrawerOpen(false)} + showSearch={showChainSearch} + onBack={() => setShowChainSearch(false)} + /> ( + {children} +); + +describe('PickerHeader', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders with title and close button', () => { + render(, { wrapper: AppWrapper }); + + expect(screen.getByText('Select token')).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument(); + expect(screen.getByLabelText('Close routes')).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + render(, { wrapper: AppWrapper }); + + const closeButton = screen.getByLabelText('Close routes'); + fireEvent.click(closeButton); + + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + it('shows back button when showSearch is true', () => { + const props = { ...defaultProps, showSearch: true }; + render(, { wrapper: AppWrapper }); + + expect(screen.getByLabelText('Go back')).toBeInTheDocument(); + expect(screen.getByTestId('back-button')).toBeInTheDocument(); + }); + + it('does not show back button when showSearch is false', () => { + render(, { wrapper: AppWrapper }); + + expect(screen.queryByLabelText('Go back')).not.toBeInTheDocument(); + expect(screen.queryByTestId('back-button')).not.toBeInTheDocument(); + }); + + it('calls onBack when back button is clicked', () => { + const props = { ...defaultProps, showSearch: true }; + render(, { wrapper: AppWrapper }); + + const backButton = screen.getByLabelText('Go back'); + fireEvent.click(backButton); + + expect(defaultProps.onBack).toHaveBeenCalledTimes(1); + }); + + it('does not show back button when onBack is not provided', () => { + const props = { ...defaultProps, showSearch: true, onBack: undefined }; + render(, { wrapper: AppWrapper }); + + expect(screen.queryByLabelText('Go back')).not.toBeInTheDocument(); + }); +}); diff --git a/src/views/v3/Bridge/AssetPicker/PickerHeader.tsx b/src/views/v3/Bridge/AssetPicker/PickerHeader.tsx new file mode 100644 index 000000000..c8c7b67ef --- /dev/null +++ b/src/views/v3/Bridge/AssetPicker/PickerHeader.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { useTheme } from '@mui/material'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +interface AssetPickerHeaderProps { + onClose: () => void; + showSearch?: boolean; + onBack?: () => void; +} + +function AssetPickerHeader({ + onClose, + showSearch = false, + onBack, +}: AssetPickerHeaderProps) { + const theme: any = useTheme(); + + const styles = { + header: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + position: 'relative', + padding: '16px', + paddingBottom: '8px', + }, + title: { + fontSize: '24px', + fontWeight: 600, + lineHeight: '32px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + iconButton: { + padding: 0, + position: 'absolute', + backgroundColor: theme.palette.background.form, + border: '1px solid ' + theme.palette.input.border, + '&:hover': { backgroundColor: theme.palette.background.form }, + }, + icon: { + height: '16px', + width: '16px', + padding: '8px', + }, + }; + + return ( + <> + + {showSearch && onBack && ( + + + + )} + + Select token + + + + + + + ); +} + +export default React.memo(AssetPickerHeader); diff --git a/src/views/v3/Bridge/AssetPicker/PickerModal.tsx b/src/views/v3/Bridge/AssetPicker/PickerModal.tsx index 3da1386f0..4024d1a91 100644 --- a/src/views/v3/Bridge/AssetPicker/PickerModal.tsx +++ b/src/views/v3/Bridge/AssetPicker/PickerModal.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import React from 'react'; import { useTheme } from '@mui/material'; import Popover from '@mui/material/Popover'; import { bindPopover } from 'material-ui-popup-state/hooks'; @@ -11,6 +11,7 @@ import type { Token } from 'config/tokens'; import type { Balances } from 'utils/wallet/types'; import ChainList from 'views/v3/Bridge/AssetPicker/ChainList'; import TokenList from 'views/v3/Bridge/AssetPicker/TokenList'; +import PickerHeader from './PickerHeader'; interface AssetPickerPopoverProps { popupState: PopupState; @@ -77,7 +78,7 @@ function AssetPickerPopover({ paper: { sx: { width: '100%', - maxWidth: '420px', + maxWidth: '452px', borderRadius: '8px', background: theme.palette.input.background, backdropFilter: 'blur(4px)', @@ -93,6 +94,11 @@ function AssetPickerPopover({ }, }} > + popupState.close()} + showSearch={showChainSearch} + onBack={() => setShowChainSearch(false)} + /> (props: SearchableListProps): ReactNode { const { items, filterFn, searchQuery } = props; + const listTitle = useMemo(() => { + if (!props.listTitle) { + // We have 16px top space in the default case when no title override is given + return
; + } + return {props.listTitle}; + }, [props.listTitle]); + const filteredList = useMemo(() => { if (!filterFn) { return items; @@ -66,11 +74,11 @@ function SearchableList(props: SearchableListProps): ReactNode { sx={{ ...styles.searchList, ...scrollbarStyles }} data-testid={props.dataTestId} > - {props.listTitle} + {listTitle} {props.loading || filteredList.map(props.renderFn)} ); } -export default memo(SearchableList) as typeof SearchableList; +export default React.memo(SearchableList) as typeof SearchableList; diff --git a/src/views/v3/Bridge/AssetPicker/TokenItem.test.tsx b/src/views/v3/Bridge/AssetPicker/TokenItem.test.tsx new file mode 100644 index 000000000..f007c2c21 --- /dev/null +++ b/src/views/v3/Bridge/AssetPicker/TokenItem.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material'; + +import TokenItem from './TokenItem'; +import { dark } from 'theme'; + +const theme = createTheme({ + palette: dark as any, +}); + +vi.mock('utils', () => ({ + chainDisplayName: vi.fn((chain) => chain), + getTokenExplorerUrl: vi.fn(() => 'https://explorer.example.com'), + getTokenDisplaySymbolByTokenAddress: vi.fn(() => 'USDC'), +})); + +vi.mock('components/TokenBalance', () => ({ + default: ({ balance }: { balance: any }) => ( +
{balance ? '1,000' : '0'}
+ ), +})); + +const mockToken = { + key: 'USDC', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'usdc.svg', + addressString: '0xa0b86a33e6180d4c6d1cbe6c9e1f4a3d4b8a6c6e', + chain: 'Ethereum' as const, + address: '0xa0b86a33e6180d4c6d1cbe6c9e1f4a3d4b8a6c6e', + tokenId: { + chain: 'Ethereum' as const, + address: '0xa0b86a33e6180d4c6d1cbe6c9e1f4a3d4b8a6c6e', + }, + display: 'USD Coin', + shortAddress: '0xa0b...a6c6e', + tuple: ['Ethereum', '0xa0b86a33e6180d4c6d1cbe6c9e1f4a3d4b8a6c6e'] as [ + 'Ethereum', + string, + ], + isNativeGasToken: false, + isTokenBridgeWrappedToken: false, + nativeChain: 'Ethereum' as const, + equals: vi.fn(), + toJson: vi.fn(), +} as any; + +const mockBalance = { + amount: '1000000000', // 1000 USDC + decimals: 6, +} as any; + +const defaultProps = { + token: mockToken, + chain: 'Ethereum' as const, + balance: mockBalance, + price: '1.00', + onClick: vi.fn(), + isSelected: false, + isFetchingBalance: false, + isSource: true, + isDimmed: false, +}; + +const AppWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('TokenItem', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders token information correctly', () => { + render(, { wrapper: AppWrapper }); + + expect(screen.getAllByText('USDC')).toHaveLength(2); // Symbol appears twice in UI + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('calls onClick when token item is clicked', () => { + render(, { wrapper: AppWrapper }); + + const tokenButton = screen.getByRole('button'); + fireEvent.mouseDown(tokenButton); + + expect(defaultProps.onClick).toHaveBeenCalledTimes(1); + }); + + it('displays balance when provided', () => { + render(, { wrapper: AppWrapper }); + + expect(screen.getByTestId('token-balance')).toBeInTheDocument(); + expect(screen.getByText('1,000')).toBeInTheDocument(); + }); + + it('shows loading state when fetching balance', () => { + const props = { ...defaultProps, isFetchingBalance: true }; + render(, { wrapper: AppWrapper }); + + expect(screen.getByTestId('token-balance')).toBeInTheDocument(); + }); + + it('displays "0" when balance is null', () => { + const props = { ...defaultProps, balance: null }; + render(, { wrapper: AppWrapper }); + + expect(screen.getByTestId('token-balance')).toBeInTheDocument(); + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('shows external link for explorer URL', () => { + render(, { wrapper: AppWrapper }); + + const link = screen.getByRole('link'); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', 'https://explorer.example.com'); + }); +}); diff --git a/src/views/v3/Bridge/AssetPicker/TokenItem.tsx b/src/views/v3/Bridge/AssetPicker/TokenItem.tsx index 63ec30844..1b8249274 100644 --- a/src/views/v3/Bridge/AssetPicker/TokenItem.tsx +++ b/src/views/v3/Bridge/AssetPicker/TokenItem.tsx @@ -1,4 +1,4 @@ -import React, { memo, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { Box, Tooltip } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import ListItemButton from '@mui/material/ListItemButton'; @@ -165,4 +165,4 @@ function TokenItem(props: TokenItemProps) { ); } -export default memo(TokenItem); +export default React.memo(TokenItem); diff --git a/src/views/v3/Bridge/AssetPicker/TokenList.test.tsx b/src/views/v3/Bridge/AssetPicker/TokenList.test.tsx new file mode 100644 index 000000000..8aa4d8f64 --- /dev/null +++ b/src/views/v3/Bridge/AssetPicker/TokenList.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material'; + +import TokenList from './TokenList'; +import { dark } from 'theme'; + +const theme = createTheme({ + palette: dark as any, +}); + +// Mock SearchableList to simplify testing +vi.mock('views/v3/Bridge/AssetPicker/SearchableList', () => ({ + default: ({ searchQuery, onQueryChange }: any) => ( +
+ onQueryChange?.(e.target.value)} + /> +
Mocked SearchableList
+
+ ), +})); + +vi.mock('hooks/useTokenListWithSearch', () => ({ + useTokenListWithSearch: vi.fn(() => ({ + sortedTokens: [], + tokenPrices: {}, + })), +})); + +vi.mock('hooks/useTokenListGrouping', () => ({ + useTokenListGrouping: vi.fn(() => ({ + groupedTokens: [], + })), +})); + +const mockChainConfig = { + key: 'Ethereum', + displayName: 'Ethereum', + sdkName: 'Ethereum' as const, + icon: 'Ethereum' as const, + explorerUrl: 'https://etherscan.io', + explorerName: 'Etherscan', +}; + +const mockWallet = { + type: 'Evm' as const, + address: '0x123', + currentAddress: '0x123', + error: '', + name: 'Test Wallet', +}; + +const defaultProps = { + tokenList: [], + balances: {}, + isFetchingBalances: false, + isFetching: false, + isConnectingWallet: false, + selectedChainConfig: mockChainConfig, + selectedToken: undefined, + sourceToken: undefined, + isSameChainSwap: false, + isSource: true, + wallet: mockWallet, + searchQuery: '', + onSearchQueryChange: vi.fn(), + onSelectToken: vi.fn(), + fetchTokensProgress: null, +}; + +const AppWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('TokenList', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders token list with search input', () => { + render(, { wrapper: AppWrapper }); + + expect(screen.getByPlaceholderText('Search tokens')).toBeInTheDocument(); + }); + + it('renders mocked searchable list', () => { + render(, { wrapper: AppWrapper }); + + expect(screen.getByText('Mocked SearchableList')).toBeInTheDocument(); + }); + + it('calls onSearchQueryChange when search input changes', () => { + render(, { wrapper: AppWrapper }); + + const searchInput = screen.getByPlaceholderText('Search tokens'); + fireEvent.change(searchInput, { target: { value: 'USDC' } }); + + expect(defaultProps.onSearchQueryChange).toHaveBeenCalledWith('USDC'); + }); +}); diff --git a/src/views/v3/Bridge/AssetPicker/TokenList.tsx b/src/views/v3/Bridge/AssetPicker/TokenList.tsx index da16a33d0..4727828e1 100644 --- a/src/views/v3/Bridge/AssetPicker/TokenList.tsx +++ b/src/views/v3/Bridge/AssetPicker/TokenList.tsx @@ -80,7 +80,7 @@ const TokenList = (props: Props) => { () => ({ card: { background: theme.palette.input.background, - maxWidth: '420px', + maxWidth: '452px', }, tokenListContainer: { padding: '16px 0 0 0 !important', diff --git a/src/views/v3/Bridge/AssetPicker/index.tsx b/src/views/v3/Bridge/AssetPicker/index.tsx index 901cea603..e7b23618e 100644 --- a/src/views/v3/Bridge/AssetPicker/index.tsx +++ b/src/views/v3/Bridge/AssetPicker/index.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Box, TextField, Tooltip, useMediaQuery } from '@mui/material'; import { useTheme } from '@mui/material/styles'; @@ -188,7 +188,7 @@ function AssetPicker(props: Props) { const styles = useMemo( () => ({ root: { - maxWidth: '420px', + maxWidth: '452px', background: theme.palette.input.background, borderRadius: '8px', padding: '16px', @@ -634,4 +634,4 @@ function AssetPicker(props: Props) { ); } -export default memo(AssetPicker); +export default React.memo(AssetPicker); diff --git a/src/views/v3/Bridge/BridgeTitle.tsx b/src/views/v3/Bridge/BridgeTitle.tsx new file mode 100644 index 000000000..e10a296e0 --- /dev/null +++ b/src/views/v3/Bridge/BridgeTitle.tsx @@ -0,0 +1,97 @@ +import React, { useMemo } from 'react'; +import { useTheme, useMediaQuery } from '@mui/material'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; + +import Header from 'components/Header'; +import ConfigurablePageHeader from 'components/ConfigurablePageHeader'; +import HistoryIcon from 'icons/History'; +import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; +import TxHistoryWidget from 'views/v3/TxHistory/Widget'; +import config from 'config'; +import { OPACITY } from 'utils/style'; +import BridgeIcon from 'icons/Bridge'; + +interface BridgeTitleProps { + showHistory: boolean; + isTransactionInProgress: boolean; + isWalletConnected: boolean; + onToggleHistory: () => void; +} + +const BridgeTitle: React.FC = ({ + showHistory, + isTransactionInProgress, + isWalletConnected, + onToggleHistory, +}) => { + const theme: any = useTheme(); + const mobile = useMediaQuery(theme.breakpoints.down('sm')); + + const styles = useMemo( + () => ({ + titleContent: { + maxWidth: mobile ? '420px' : '452px', + }, + bridgeHeader: { + width: '100%', + minHeight: '28px', + display: 'flex', + alignItems: 'center', + padding: '20px 0', + }, + }), + [mobile], + ); + + const isTxHistoryDisabled = !isWalletConnected || isTransactionInProgress; + + const iconTooltip = + (!isWalletConnected && 'No connected wallets found') || + (showHistory ? 'Show bridge' : 'Show history'); + + return ( + + + {config.ui.showInProgressWidget && ( + + )} + + +
+ + + + {showHistory ? ( + + ) : ( + + )} + + + + + + ); +}; + +export default React.memo(BridgeTitle); diff --git a/src/views/v3/Bridge/Routes/Eta.tsx b/src/views/v3/Bridge/Routes/Eta.tsx index f110967f1..84a5d5ffe 100644 --- a/src/views/v3/Bridge/Routes/Eta.tsx +++ b/src/views/v3/Bridge/Routes/Eta.tsx @@ -36,7 +36,7 @@ function Eta({ eta }: EtaProps) { > ({ - assetPickerTitle: { - color: theme.palette.text.secondary, - display: 'flex', - minHeight: '40px', - alignItems: 'center', - justifyContent: 'space-between', - }, bridgeContent: { margin: 'auto', maxWidth: '452px', }, - bridgeHeader: { - width: '100%', - minHeight: '28px', - display: 'flex', - alignItems: 'center', - padding: '20px 0', - }, doneIcon: { fontSize: '14px', color: theme.palette.success.main, @@ -129,19 +110,10 @@ function Bridge(props: BridgeProps) { flexDirection: 'column', gap: '16px', }, - titleContent: { - maxWidth: mobile ? '420px' : '452px', - }, }), - [ - mobile, - theme.palette.background.form, - theme.palette.success.main, - theme.palette.text.secondary, - ], + [theme.palette.background.form, theme.palette.success.main], ); - // --- pipeline state gathering --- // Connected wallets, if any const { sending: sendingWallet, receiving: receivingWallet } = useSelector( (state: RootState) => state.wallet, @@ -164,7 +136,6 @@ function Bridge(props: BridgeProps) { const { sourceToken, destToken } = useGetTokens(); const isSameChainSwap = sourceChain === destChain; - // --- pipeline usage --- const { allSupportedRoutes, sortedRoutes, @@ -350,18 +321,19 @@ function Bridge(props: BridgeProps) { // Handler for history toggle const handleHistoryToggle = useCallback(() => { - setShowHistory((value) => !value); - config.triggerEvent({ - type: 'history.load', - details: { - wallet: sendingWallet?.address, - }, + setShowHistory((value) => { + // Log event when opening history + if (!value) { + config.triggerEvent({ + type: 'history.load', + details: { + wallet: sendingWallet?.address, + }, + }); + } + return !value; }); }, [sendingWallet?.address]); - - const isTxHistoryDisabled = - !sendingWallet?.address || isTransactionInProgress; - // Handler for route change const handleRouteChange = useCallback( (r: string) => { @@ -442,7 +414,6 @@ function Bridge(props: BridgeProps) { }, [styles.copyIcon, styles.doneIcon, errorCopied, txError, txErrorInternal]); const hasEnteredAmount = amount && sdkAmount.whole(amount) > 0; - const hasConnectedWallets = sendingWallet.address && receivingWallet.address; const confirmTransactionDisabled = @@ -519,132 +490,102 @@ function Bridge(props: BridgeProps) { const bridgeContent = ( <> - - {/* Source asset picker */} - - - - {/* Swap source/destination assets button */} - - {/* Destination asset picker */} - - - - {hasConnectedWallets ? ( - - {confirmTransactionButton} - - ) : walletConnectorProps ? ( - - ) : null} - - {transactionError} - + + {showHistory ? ( + + ) : ( + <> + + {/* Source asset picker */} + + + + {/* Swap source/destination assets button */} + + {/* Destination asset picker */} + + + + {hasConnectedWallets ? ( + + {confirmTransactionButton} + + ) : walletConnectorProps ? ( + + ) : null} + + {transactionError} + + {hasEnteredAmount && ( + + + + )} + + )} ); - const iconTooltip = - (!sendingWallet?.address && 'No connected wallets found') || - (showHistory ? 'Show bridge' : 'Show history'); - return ( - - - - {config.ui.showInProgressWidget && ( - - )} - -
- - - - {showHistory ? ( - - ) : ( - - )} - - - - - - + - {showHistory ? : bridgeContent} + {bridgeContent} - {hasEnteredAmount && !showHistory && ( - - - - )} {config.ui.showFooter && ( <> diff --git a/vitest.config.ts b/vitest.config.mts similarity index 100% rename from vitest.config.ts rename to vitest.config.mts