diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 6c477d81fdf0..71faedfb04d9 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -11,7 +11,13 @@ import React, { useEffect, useRef, } from 'react'; -import { SafeAreaView, ScrollView, View, RefreshControl } from 'react-native'; +import { + SafeAreaView, + ScrollView, + View, + RefreshControl, + Linking, +} from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { strings } from '../../../../../../locales/i18n'; import Button, { @@ -53,9 +59,7 @@ import { PerpsEventProperties, PerpsEventValues, } from '../../constants/eventNames'; -import { useSelector } from 'react-redux'; -import { selectPerpsProvider } from '../../selectors/perpsController'; -import { capitalize } from '../../../../../util/general'; + import { usePerpsAccount, usePerpsConnection, @@ -104,8 +108,6 @@ const PerpsMarketDetailsView: React.FC = () => { const [activeTabId, setActiveTabId] = useState('position'); const [refreshing, setRefreshing] = useState(false); - const perpsProvider = useSelector(selectPerpsProvider); - const account = usePerpsAccount(); usePerpsConnection(); @@ -284,6 +286,12 @@ const PerpsMarketDetailsView: React.FC = () => { }); }; + const handleTradingViewPress = useCallback(() => { + Linking.openURL('https://www.tradingview.com/').catch((error) => { + console.error('Failed to open Trading View URL:', error); + }); + }, []); + // Determine if any action buttons will be visible const hasLongShortButtons = useMemo( () => !isLoadingPosition && !hasZeroBalance, @@ -387,9 +395,14 @@ const PerpsMarketDetailsView: React.FC = () => { variant={TextVariant.BodyXS} color={TextColor.Alternative} > - {strings('perps.risk_disclaimer', { - provider: capitalize(perpsProvider), - })} + {strings('perps.risk_disclaimer')}{' '} + + Trading View + diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index 6ecbf10c3880..e081da862eea 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -160,11 +160,29 @@ const PerpsMarketListView = ({ }; const filteredMarkets = useMemo(() => { + // First filter out markets with no volume or $0 volume + const marketsWithVolume = markets.filter((market: PerpsMarketData) => { + // Check if volume exists and is not zero + if ( + !market.volume || + market.volume === '$0' || + market.volume === '$0.00' + ) { + return false; + } + // Also filter out fallback display values + if (market.volume === '$---' || market.volume === '---') { + return false; + } + return true; + }); + + // Then apply search filter if needed if (!searchQuery.trim()) { - return markets; + return marketsWithVolume; } const query = searchQuery.toLowerCase().trim(); - return markets.filter( + return marketsWithVolume.filter( (market: PerpsMarketData) => market.symbol.toLowerCase().includes(query) || market.name.toLowerCase().includes(query), diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 764a8da3624b..453c9fa6c473 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -650,7 +650,7 @@ const PerpsOrderViewContentBase: React.FC = () => { {/* Amount Display */} { value={parseFloat(orderForm.amount || '0')} onValueChange={(value) => setAmount(Math.floor(value).toString())} minimumValue={0} - maximumValue={availableBalance} + maximumValue={availableBalance * orderForm.leverage} step={1} showPercentageLabels /> diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx index bbe44c8fcbe7..d53c6bdfe98d 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx @@ -1,6 +1,7 @@ import { useNavigation } from '@react-navigation/native'; import { act, fireEvent, render, screen } from '@testing-library/react-native'; import React from 'react'; +import { useSelector } from 'react-redux'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; import type { Position } from '../../controllers/types'; @@ -12,6 +13,20 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), })); +// Mock Redux +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +// Mock the multichain selector +jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(() => () => ({ + address: '0x1234567890123456789012345678901234567890', + id: 'mock-account-id', + type: 'eip155:eoa', + })), +})); + // Mock PerpsConnectionProvider jest.mock('../../providers/PerpsConnectionProvider', () => ({ PerpsConnectionProvider: ({ children }: { children: React.ReactNode }) => @@ -126,10 +141,20 @@ describe('PerpsTabView', () => { jest.clearAllMocks(); (useNavigation as jest.Mock).mockReturnValue(mockNavigation); + // Mock useSelector for the multichain selector + (useSelector as jest.Mock).mockImplementation(() => () => ({ + address: '0x1234567890123456789012345678901234567890', + id: 'mock-account-id', + type: 'eip155:eoa', + })); + // Default hook mocks mockUsePerpsConnection.mockReturnValue({ isConnected: true, isInitialized: true, + error: null, + connect: jest.fn(), + resetError: jest.fn(), }); mockUsePerpsLivePositions.mockReturnValue({ @@ -477,6 +502,69 @@ describe('PerpsTabView', () => { consoleSpy.mockRestore(); }); + + it('should render connection error state when connection fails', () => { + mockUsePerpsConnection.mockReturnValue({ + isConnected: false, + isInitialized: false, + error: 'CONNECTION_FAILED', + connect: jest.fn(), + resetError: jest.fn(), + }); + + render(); + + // Should show connection failed error + expect( + screen.getByText(strings('perps.errors.connectionFailed.title')), + ).toBeOnTheScreen(); + expect( + screen.getByText(strings('perps.errors.connectionFailed.description')), + ).toBeOnTheScreen(); + }); + + it('should render network error state when network error occurs', () => { + mockUsePerpsConnection.mockReturnValue({ + isConnected: false, + isInitialized: false, + error: 'NETWORK_ERROR', + connect: jest.fn(), + resetError: jest.fn(), + }); + + render(); + + // Should show connection failed error (PerpsTabView always uses CONNECTION_FAILED) + expect( + screen.getByText(strings('perps.errors.connectionFailed.title')), + ).toBeOnTheScreen(); + expect( + screen.getByText(strings('perps.errors.connectionFailed.description')), + ).toBeOnTheScreen(); + }); + + it('should call connect when retry button is pressed on error', () => { + const mockConnect = jest.fn(); + const mockResetError = jest.fn(); + + mockUsePerpsConnection.mockReturnValue({ + isConnected: false, + isInitialized: false, + error: 'CONNECTION_FAILED', + connect: mockConnect, + resetError: mockResetError, + }); + + render(); + + const retryButton = screen.getByText( + strings('perps.errors.connectionFailed.retry'), + ); + fireEvent.press(retryButton); + + expect(mockResetError).toHaveBeenCalledTimes(1); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); }); describe('Accessibility', () => { diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx index 24fb0413afc2..4fea1cbc647f 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx @@ -1,6 +1,7 @@ import { useNavigation, type NavigationProp } from '@react-navigation/native'; import React, { useCallback, useEffect, useRef } from 'react'; import { ScrollView, View } from 'react-native'; +import { useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; import Button, { ButtonSize, @@ -21,6 +22,9 @@ import Routes from '../../../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; import PerpsPositionCard from '../../components/PerpsPositionCard'; import { PerpsTabControlBar } from '../../components/PerpsTabControlBar'; +import PerpsErrorState, { + PerpsErrorType, +} from '../../components/PerpsErrorState'; import { PerpsEventProperties, PerpsEventValues, @@ -36,6 +40,7 @@ import { usePerpsPerformance, usePerpsLivePositions, } from '../../hooks'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import styleSheet from './PerpsTabView.styles'; interface PerpsTabViewProps {} @@ -43,8 +48,12 @@ interface PerpsTabViewProps {} const PerpsTabView: React.FC = () => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation>(); + const selectedEvmAccount = useSelector(selectSelectedInternalAccountByScope)( + 'eip155:1', + ); const { getAccountState } = usePerpsTrading(); - const { isConnected, isInitialized } = usePerpsConnection(); + const { isConnected, isInitialized, error, connect, resetError } = + usePerpsConnection(); const { track } = usePerpsEventTracking(); const cachedAccountState = usePerpsAccount(); @@ -64,15 +73,15 @@ const PerpsTabView: React.FC = () => { startMeasure(PerpsMeasurementName.POSITION_DATA_LOADED_PERP_TAB); }, [startMeasure]); - // Automatically load account state on mount and when network changes + // Automatically load account state on mount and when network or account changes useEffect(() => { - // Only load account state if we're connected and initialized - if (isConnected && isInitialized) { + // Only load account state if we're connected, initialized, and have an EVM account + if (isConnected && isInitialized && selectedEvmAccount) { // Fire and forget - errors are already handled in getAccountState // and stored in the controller's state getAccountState(); } - }, [getAccountState, isConnected, isInitialized]); + }, [getAccountState, isConnected, isInitialized, selectedEvmAccount]); // Track homescreen tab viewed - only once when positions and account are loaded useEffect(() => { @@ -123,6 +132,11 @@ const PerpsTabView: React.FC = () => { }); }, [navigation]); + const handleRetryConnection = useCallback(() => { + resetError(); + connect(); + }, [connect, resetError]); + const renderPositionsSection = () => { if (isInitialLoading) { return ( @@ -208,6 +222,18 @@ const PerpsTabView: React.FC = () => { ); }; + // Check for connection errors + if (error && !isConnected && selectedEvmAccount) { + return ( + + + + ); + } + return ( {isFirstTimeUser ? ( diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.test.tsx index 5d93d6aa8894..865a08693324 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; import Routes from '../../../../../constants/navigation/Routes'; import PerpsFundingTransactionView from './PerpsFundingTransactionView'; import { PerpsTransactionSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; @@ -23,7 +24,6 @@ const mockTransaction = { // Mock all dependencies properly const mockUseNavigation = jest.fn(); const mockUseRoute = jest.fn(); -const mockUseSelector = jest.fn(); const mockUsePerpsNetwork = jest.fn(); const mockUsePerpsBlockExplorerUrl = jest.fn(); const mockGetHyperliquidExplorerUrl = jest.fn(); @@ -37,7 +37,13 @@ jest.mock('@react-navigation/native', () => ({ })); jest.mock('react-redux', () => ({ - useSelector: () => mockUseSelector(), + useSelector: jest.fn(), +})); + +jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(() => () => ({ + address: '0x1234567890abcdef1234567890abcdef12345678', + })), })); jest.mock('../../hooks', () => ({ @@ -69,9 +75,10 @@ describe('PerpsFundingTransactionView', () => { ), baseExplorerUrl: 'https://app.hyperliquid.xyz/explorer', }); - mockUseSelector.mockReturnValue({ + // Mock useSelector to return a function that returns the account + (useSelector as jest.Mock).mockImplementation(() => () => ({ address: '0x1234567890abcdef1234567890abcdef12345678', - }); + })); mockUseRoute.mockReturnValue({ params: { transaction: mockTransaction }, }); @@ -263,7 +270,8 @@ describe('PerpsFundingTransactionView', () => { setOptions: jest.fn(), }); - mockUseSelector.mockReturnValue(null); + // Mock useSelector to return null for no account + (useSelector as jest.Mock).mockImplementationOnce(() => () => null); const { getByTestId } = render(); diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx index 1871557fd949..e5cea34c5964 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx @@ -19,7 +19,7 @@ import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks'; -import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import ScreenView from '../../../../Base/ScreenView'; import { getPerpsTransactionsDetailsNavbar } from '../../../Navbar'; import { usePerpsBlockExplorerUrl } from '../../hooks'; @@ -40,7 +40,9 @@ const PerpsFundingTransactionView: React.FC = () => { const navigation = useNavigation>(); const route = useRoute(); - const selectedInternalAccount = useSelector(selectSelectedInternalAccount); + const selectedInternalAccount = useSelector( + selectSelectedInternalAccountByScope, + )('eip155:1'); const { getExplorerUrl } = usePerpsBlockExplorerUrl(); // Get transaction from route params diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx index 6d7b745c0525..983d9b18036a 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; import Routes from '../../../../../constants/navigation/Routes'; import PerpsOrderTransactionView from './PerpsOrderTransactionView'; import { @@ -30,7 +31,6 @@ const mockTransaction = { // Mock dependencies const mockUseNavigation = jest.fn(); const mockUseRoute = jest.fn(); -const mockUseSelector = jest.fn(); jest.mock('@react-navigation/native', () => ({ useNavigation: () => mockUseNavigation(), @@ -38,7 +38,13 @@ jest.mock('@react-navigation/native', () => ({ })); jest.mock('react-redux', () => ({ - useSelector: () => mockUseSelector(), + useSelector: jest.fn(), +})); + +jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(() => () => ({ + address: '0x1234567890abcdef1234567890abcdef12345678', + })), })); jest.mock('../../hooks', () => ({ @@ -64,32 +70,14 @@ describe('PerpsOrderTransactionView', () => { typeof usePerpsBlockExplorerUrl >; - // Mock selectedInternalAccount - const mockSelectedInternalAccount = { - id: 'test-account-id', - address: '0x1234567890abcdef1234567890abcdef12345678', - type: 'eip155:eoa' as const, - metadata: { - name: 'Test Account', - importTime: 1684232000456, - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: [ - 'personal_sign', - 'eth_signTransaction', - 'eth_signTypedData_v1', - 'eth_signTypedData_v3', - 'eth_signTypedData_v4', - ], - scopes: ['eip155:1'], - }; - beforeEach(() => { jest.clearAllMocks(); + // Mock useSelector to return a function that returns the account + (useSelector as jest.Mock).mockImplementation(() => () => ({ + address: '0x1234567890abcdef1234567890abcdef12345678', + })); + mockUsePerpsNetwork.mockReturnValue('mainnet'); mockUsePerpsBlockExplorerUrl.mockReturnValue({ getExplorerUrl: jest.fn().mockImplementation((address) => { @@ -124,9 +112,6 @@ describe('PerpsOrderTransactionView', () => { navigate: jest.fn(), setOptions: jest.fn(), }); - - // Mock selectedInternalAccount by default - mockUseSelector.mockReturnValue(mockSelectedInternalAccount); }); it('should render order transaction details correctly', () => { @@ -249,7 +234,8 @@ describe('PerpsOrderTransactionView', () => { setOptions: jest.fn(), }); - mockUseSelector.mockReturnValue(null); + // Mock useSelector to return null for no account + (useSelector as jest.Mock).mockImplementationOnce(() => () => null); const { getByTestId } = render(); diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx index b6fd768a6220..c6eee742d05e 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx @@ -20,7 +20,7 @@ import Button, { ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; import { useStyles } from '../../../../../component-library/hooks'; -import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import ScreenView from '../../../../Base/ScreenView'; import { getPerpsTransactionsDetailsNavbar } from '../../../Navbar'; import PerpsTransactionDetailAssetHero from '../../components/PerpsTransactionDetailAssetHero'; @@ -37,7 +37,9 @@ const PerpsOrderTransactionView: React.FC = () => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation>(); const route = useRoute(); - const selectedInternalAccount = useSelector(selectSelectedInternalAccount); + const selectedInternalAccount = useSelector( + selectSelectedInternalAccountByScope, + )('eip155:1'); const { getExplorerUrl } = usePerpsBlockExplorerUrl(); // Get transaction from route params const transaction = route.params?.transaction; diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx index 183844a2724f..89bc7b5994fb 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx @@ -3,6 +3,7 @@ import { fireEvent } from '@testing-library/react-native'; import PerpsPositionTransactionView from './PerpsPositionTransactionView'; import { usePerpsNetwork, usePerpsBlockExplorerUrl } from '../../hooks'; import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import renderWithProvider, { DeepPartial, } from '../../../../../util/test/renderWithProvider'; @@ -52,6 +53,13 @@ jest.mock('../../../../../selectors/accountsController', () => ({ selectSelectedInternalAccountAddress: jest.fn(), selectSelectedInternalAccountFormattedAddress: jest.fn(), selectHasCreatedSolanaMainnetAccount: jest.fn(), + selectInternalAccounts: jest.fn(() => []), +})); + +jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(() => () => ({ + address: '0x1234567890abcdef1234567890abcdef12345678', + })), })); const mockTransaction = { @@ -334,9 +342,11 @@ describe('PerpsPositionTransactionView', () => { }); it('should not navigate to block explorer when no selected account', () => { - (selectSelectedInternalAccount as unknown as jest.Mock).mockReturnValue( - null, - ); + // Mock the multichain selector to return undefined + jest + .mocked(selectSelectedInternalAccountByScope) + .mockReturnValueOnce(() => undefined); + const { getByText } = renderWithProvider(, { state: mockInitialState, }); diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx index 4d8cd4718c7e..63e52a6594a8 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx @@ -19,7 +19,7 @@ import Button, { ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; import { useStyles } from '../../../../../component-library/hooks'; -import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import ScreenView from '../../../../Base/ScreenView'; import { getPerpsTransactionsDetailsNavbar } from '../../../Navbar'; import PerpsTransactionDetailAssetHero from '../../components/PerpsTransactionDetailAssetHero'; @@ -40,7 +40,9 @@ const PerpsPositionTransactionView: React.FC = () => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation>(); const route = useRoute(); - const selectedInternalAccount = useSelector(selectSelectedInternalAccount); + const selectedInternalAccount = useSelector( + selectSelectedInternalAccountByScope, + )('eip155:1'); const { getExplorerUrl } = usePerpsBlockExplorerUrl(); // Get transaction from route params diff --git a/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.test.tsx b/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.test.tsx index a4672deee909..e243633ec4d5 100644 --- a/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.test.tsx +++ b/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.test.tsx @@ -120,6 +120,9 @@ describe('PerpsAmountDisplay', () => { render(); expect(formatPrice).toHaveBeenCalledWith('1234.56', { minimumDecimals: 0 }); - expect(formatPrice).toHaveBeenCalledWith(9876.54); + expect(formatPrice).toHaveBeenCalledWith(9876.54, { + minimumDecimals: 2, + maximumDecimals: 2, + }); }); }); diff --git a/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.tsx b/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.tsx index b1e29245dfac..92ea34f3e805 100644 --- a/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.tsx +++ b/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.tsx @@ -1,13 +1,14 @@ import React, { useEffect, useRef } from 'react'; -import { View, TouchableOpacity, Animated, Text as RNText } from 'react-native'; +import { Animated, Text as RNText, TouchableOpacity, View } from 'react-native'; +import { PerpsAmountDisplaySelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import Text, { - TextVariant, TextColor, + TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { useTheme } from '../../../../../util/theme'; import { formatPrice } from '../../utils/formatUtils'; -import { PerpsAmountDisplaySelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import createStyles from './PerpsAmountDisplay.styles'; +import { strings } from '../../../../../../locales/i18n'; interface PerpsAmountDisplayProps { amount: string; @@ -77,11 +78,12 @@ const PerpsAmountDisplay: React.FC = ({ )} - {formatPrice(maxAmount)} max + {formatPrice(maxAmount, { minimumDecimals: 2, maximumDecimals: 2 })}{' '} + {strings('perps.order.max')} {showWarning && ( - {formatPrice(closeAmountUSD)} + {formatPrice(closeAmountUSD, { + minimumDecimals: 2, + maximumDecimals: 2, + })} = 0 ? TextColor.Success : TextColor.Error} > - {pnl >= 0 ? '+' : ''} - {formatPrice(pnl * (closePercentage / 100))} + {pnl >= 0 ? '+' : '-'} + {formatPrice(Math.abs(pnl * (closePercentage / 100)), { + minimumDecimals: 2, + maximumDecimals: 2, + })} @@ -416,7 +419,11 @@ const PerpsClosePositionBottomSheet: React.FC< variant={TextVariant.BodyMD} color={TextColor.Default} > - -{formatPrice(feeResults.totalFee)} + - + {formatPrice(feeResults.totalFee, { + minimumDecimals: 2, + maximumDecimals: 2, + })} @@ -432,7 +439,10 @@ const PerpsClosePositionBottomSheet: React.FC< variant={TextVariant.BodyLGMedium} color={TextColor.Default} > - {formatPrice(receiveAmount)} + {formatPrice(receiveAmount, { + minimumDecimals: 2, + maximumDecimals: 2, + })} diff --git a/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.styles.ts b/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.styles.ts new file mode 100644 index 000000000000..3abc3527308f --- /dev/null +++ b/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.styles.ts @@ -0,0 +1,39 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background.default, + paddingHorizontal: 16, + }, + content: { + alignItems: 'center', + maxWidth: 320, + width: '100%', + }, + icon: { + marginBottom: 24, + }, + title: { + marginBottom: 12, + textAlign: 'center', + }, + description: { + marginBottom: 32, + textAlign: 'center', + lineHeight: 20, + }, + button: { + marginTop: 8, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.test.tsx b/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.test.tsx new file mode 100644 index 000000000000..34a1a5d6d173 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsErrorState, { PerpsErrorType } from './PerpsErrorState'; +import { strings } from '../../../../../../locales/i18n'; + +describe('PerpsErrorState', () => { + describe('CONNECTION_FAILED error type', () => { + it('should render connection failed error with retry button', () => { + const onRetryMock = jest.fn(); + const { getByText } = render( + , + ); + + expect( + getByText(strings('perps.errors.connectionFailed.title')), + ).toBeTruthy(); + expect( + getByText(strings('perps.errors.connectionFailed.description')), + ).toBeTruthy(); + + const retryButton = getByText( + strings('perps.errors.connectionFailed.retry'), + ); + expect(retryButton).toBeTruthy(); + + fireEvent.press(retryButton); + expect(onRetryMock).toHaveBeenCalledTimes(1); + }); + + it('should render connection failed without retry when onRetry not provided', () => { + const { getByText, queryByText } = render( + , + ); + + expect( + getByText(strings('perps.errors.connectionFailed.title')), + ).toBeTruthy(); + expect( + queryByText(strings('perps.errors.connectionFailed.retry')), + ).toBeNull(); + }); + }); + + describe('NETWORK_ERROR error type', () => { + it('should render network error with retry button', () => { + const onRetryMock = jest.fn(); + const { getByText } = render( + , + ); + + expect( + getByText(strings('perps.errors.networkError.title')), + ).toBeTruthy(); + expect( + getByText(strings('perps.errors.networkError.description')), + ).toBeTruthy(); + + const retryButton = getByText(strings('perps.errors.networkError.retry')); + expect(retryButton).toBeTruthy(); + + fireEvent.press(retryButton); + expect(onRetryMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('UNKNOWN error type', () => { + it('should render unknown error with retry button when onRetry provided', () => { + const onRetryMock = jest.fn(); + const { getByText } = render( + , + ); + + expect(getByText(strings('perps.errors.unknown.title'))).toBeTruthy(); + expect( + getByText(strings('perps.errors.unknown.description')), + ).toBeTruthy(); + + const retryButton = getByText(strings('perps.errors.unknown.retry')); + expect(retryButton).toBeTruthy(); + + fireEvent.press(retryButton); + expect(onRetryMock).toHaveBeenCalledTimes(1); + }); + + it('should render unknown error without retry button when onRetry not provided', () => { + const { getByText, queryByText } = render( + , + ); + + expect(getByText(strings('perps.errors.unknown.title'))).toBeTruthy(); + expect( + getByText(strings('perps.errors.unknown.description')), + ).toBeTruthy(); + expect(queryByText(strings('perps.errors.unknown.retry'))).toBeNull(); + }); + }); + + describe('Default behavior', () => { + it('should default to UNKNOWN error type when no errorType provided', () => { + const { getByText } = render(); + + expect(getByText(strings('perps.errors.unknown.title'))).toBeTruthy(); + expect( + getByText(strings('perps.errors.unknown.description')), + ).toBeTruthy(); + }); + + it('should use default testID when not provided', () => { + const { getByTestId } = render(); + expect(getByTestId('perps-error-state')).toBeTruthy(); + }); + + it('should use custom testID when provided', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('custom-error-state')).toBeTruthy(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.tsx b/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.tsx new file mode 100644 index 000000000000..1c250a3de0c2 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { View } from 'react-native'; +import { strings } from '../../../../../../locales/i18n'; +import Button, { + ButtonSize, + ButtonVariants, + ButtonWidthTypes, +} from '../../../../../component-library/components/Buttons/Button'; +import Icon, { + IconColor, + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './PerpsErrorState.styles'; + +export enum PerpsErrorType { + CONNECTION_FAILED = 'connection_failed', + NETWORK_ERROR = 'network_error', + UNKNOWN = 'unknown', +} + +interface PerpsErrorStateProps { + errorType?: PerpsErrorType; + onRetry?: () => void; + testID?: string; +} + +/** + * PerpsErrorState - Error state component for Perps tab + * Displays appropriate error messages and actions based on error type + */ +const PerpsErrorState: React.FC = ({ + errorType = PerpsErrorType.UNKNOWN, + onRetry, + testID = 'perps-error-state', +}) => { + const { styles } = useStyles(styleSheet, {}); + + const getErrorContent = () => { + switch (errorType) { + case PerpsErrorType.CONNECTION_FAILED: + return { + icon: IconName.Wifi, + title: strings('perps.errors.connectionFailed.title'), + description: strings('perps.errors.connectionFailed.description'), + primaryAction: { + label: strings('perps.errors.connectionFailed.retry'), + onPress: onRetry, + }, + }; + case PerpsErrorType.NETWORK_ERROR: + return { + icon: IconName.Global, + title: strings('perps.errors.networkError.title'), + description: strings('perps.errors.networkError.description'), + primaryAction: { + label: strings('perps.errors.networkError.retry'), + onPress: onRetry, + }, + }; + default: + return { + icon: IconName.Warning, + title: strings('perps.errors.unknown.title'), + description: strings('perps.errors.unknown.description'), + primaryAction: onRetry + ? { + label: strings('perps.errors.unknown.retry'), + onPress: onRetry, + } + : undefined, + }; + } + }; + + const errorContent = getErrorContent(); + const iconSize = 48 as unknown as IconSize; + + return ( + + + + + {errorContent.title} + + + {errorContent.description} + + {errorContent.primaryAction?.onPress && ( +