diff --git a/app/components/UI/AssetOverview/Balance/Balance.tsx b/app/components/UI/AssetOverview/Balance/Balance.tsx index 8dc96b67932..dd0efd0032b 100644 --- a/app/components/UI/AssetOverview/Balance/Balance.tsx +++ b/app/components/UI/AssetOverview/Balance/Balance.tsx @@ -6,8 +6,10 @@ import { useStyles } from '../../../../component-library/hooks'; import styleSheet from './Balance.styles'; import AssetElement from '../../AssetElement'; import { useSelector } from 'react-redux'; -import { selectNetworkName } from '../../../../selectors/networkInfos'; -import { selectChainId } from '../../../../selectors/networkController'; +import { + selectChainId, + selectNetworkConfigurationByChainId, +} from '../../../../selectors/networkController'; import { getTestNetImageByChainId, getDefaultNetworkByChainId, @@ -35,6 +37,7 @@ import { UnpopularNetworkList, CustomNetworkImgMapping, } from '../../../../util/networks/customNetworks'; +import { RootState } from '../../../../reducers'; interface BalanceProps { asset: TokenI; @@ -91,7 +94,9 @@ export const NetworkBadgeSource = (chainId: Hex, ticker: string) => { const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); - const networkName = useSelector(selectNetworkName); + const networkConfigurationByChainId = useSelector((state: RootState) => + selectNetworkConfigurationByChainId(state, asset.chainId as Hex), + ); const chainId = useSelector(selectChainId); const tokenChainId = isPortfolioViewEnabled() ? asset.chainId : chainId; @@ -156,7 +161,7 @@ const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => { } > diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.styles.ts b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.styles.ts new file mode 100644 index 00000000000..0bbc2abee27 --- /dev/null +++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.styles.ts @@ -0,0 +1,19 @@ +import type { Theme } from '../../../../../util/theme/models'; +import { StyleSheet } from 'react-native'; + +const stylesSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + mainContainer: { + flexGrow: 1, + paddingTop: 8, + paddingHorizontal: 16, + backgroundColor: colors.background.default, + justifyContent: 'space-between', + }, + }); +}; + +export default stylesSheet; diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx new file mode 100644 index 00000000000..fa1854612e7 --- /dev/null +++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import StakeEarningsHistoryView from './StakeEarningsHistoryView'; +import useStakingEarningsHistory from '../../hooks/useStakingEarningsHistory'; +import { MOCK_STAKED_ETH_ASSET } from '../../__mocks__/mockData'; +import { fireLayoutEvent } from '../../../../../util/testUtils/react-native-svg-charts'; +import { getStakingNavbar } from '../../../Navbar'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; +import { Hex } from '@metamask/utils'; + +jest.mock('../../../Navbar'); +jest.mock('../../hooks/useStakingEarningsHistory'); + +const mockNavigation = { + navigate: jest.fn(), + setOptions: jest.fn(), +}; + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => mockNavigation, + useRoute: () => ({ + key: '1', + name: 'params', + params: { asset: MOCK_STAKED_ETH_ASSET }, + }), + }; +}); +jest.mock('react-native-svg-charts', () => { + const reactNativeSvgCharts = jest.requireActual('react-native-svg-charts'); // Get the actual Grid component + return { + ...reactNativeSvgCharts, + Grid: () => <>, + }; +}); + +(useStakingEarningsHistory as jest.Mock).mockReturnValue({ + earningsHistory: [ + { + dateStr: '2023-01-01', + dailyRewards: '1000000000000000000', + sumRewards: '1000000000000000000', + }, + { + dateStr: '2023-01-02', + dailyRewards: '1000000000000000000', + sumRewards: '2000000000000000000', + }, + ], + isLoading: false, + error: null, +}); + +const mockInitialState = { + settings: {}, + engine: { + backgroundState: { + ...backgroundState, + CurrencyRateController: { + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionRate: 3363.79, + }, + }, + }, + NetworkController: { + selectedNetworkClientId: 'selectedNetworkClientId', + networkConfigurationsByChainId: { + '0x1': { + nativeCurrency: 'ETH', + chainId: '0x1' as Hex, + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], + defaultRpcEndpointIndex: 0, + }, + }, + }, + }, + }, +}; + +const earningsHistoryView = ; + +describe('StakeEarningsHistoryView', () => { + it('renders correctly and matches snapshot', () => { + const renderedView = renderWithProvider(earningsHistoryView, { + state: mockInitialState, + }); + fireLayoutEvent(renderedView.root); + expect(renderedView.toJSON()).toMatchSnapshot(); + }); + + it('calls navigation setOptions to get staking navigation bar', () => { + const renderedView = renderWithProvider(earningsHistoryView, { + state: mockInitialState, + }); + fireLayoutEvent(renderedView.root); + expect(mockNavigation.setOptions).toHaveBeenCalled(); + expect(getStakingNavbar).toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx new file mode 100644 index 00000000000..7be8d6485c5 --- /dev/null +++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx @@ -0,0 +1,44 @@ +import { useNavigation, useRoute } from '@react-navigation/native'; +import React, { useEffect } from 'react'; +import { View } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import { strings } from '../../../../../../locales/i18n'; +import { useStyles } from '../../../../hooks/useStyles'; +import { getStakingNavbar } from '../../../Navbar'; +import StakingEarningsHistory from '../../components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory'; +import styleSheet from './StakeEarningsHistoryView.styles'; +import { StakeEarningsHistoryViewRouteParams } from './StakeEarningsHistoryView.types'; + +const StakeEarningsHistoryView = () => { + const navigation = useNavigation(); + const route = useRoute(); + const { styles, theme } = useStyles(styleSheet, {}); + const { asset } = route.params; + + useEffect(() => { + navigation.setOptions( + getStakingNavbar( + strings('stake.earnings_history_title', { + ticker: asset.ticker, + }), + navigation, + theme.colors, + { + backgroundColor: theme.colors.background.default, + hasCancelButton: false, + hasBackButton: true, + }, + ), + ); + }, [navigation, theme.colors, asset.ticker]); + + return ( + + + + + + ); +}; + +export default StakeEarningsHistoryView; diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.types.ts b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.types.ts new file mode 100644 index 00000000000..106bb6ead4e --- /dev/null +++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.types.ts @@ -0,0 +1,9 @@ +import { TokenI } from '../../../Tokens/types'; + +export interface StakeEarningsHistoryViewRouteParams { + key: string; + name: string; + params: { + asset: TokenI; + }; +} diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/__snapshots__/StakeEarningsHistoryView.test.tsx.snap b/app/components/UI/Stake/Views/StakeEarningsHistoryView/__snapshots__/StakeEarningsHistoryView.test.tsx.snap new file mode 100644 index 00000000000..6469b6b7eda --- /dev/null +++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/__snapshots__/StakeEarningsHistoryView.test.tsx.snap @@ -0,0 +1,980 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StakeEarningsHistoryView renders correctly and matches snapshot 1`] = ` + + + + + + + + + + 7D + + + + + + + + + M + + + + + + + + + Y + + + + + + + + + + 2 + + ETH + + + Lifetime earnings + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Payout history + + + + + 2023 + + + + + + January + + + + + + + + 2 + + ETH + + + + + $6727.58 + + + + + + + + + + +`; diff --git a/app/components/UI/Stake/__mocks__/mockData.ts b/app/components/UI/Stake/__mocks__/mockData.ts index ab3e2b9ff3e..f0fcabbca3d 100644 --- a/app/components/UI/Stake/__mocks__/mockData.ts +++ b/app/components/UI/Stake/__mocks__/mockData.ts @@ -10,14 +10,29 @@ import { Contract } from 'ethers'; import { Stake } from '../sdk/stakeSdkProvider'; export const MOCK_STAKED_ETH_ASSET = { + decimals: 18, + address: '0x0000000000000000000000000000000000000000', chainId: '0x1', balance: '4.9999 ETH', balanceFiat: '$13,292.20', name: 'Staked Ethereum', - symbol: 'ETH', + symbol: 'Ethereum', + ticker: 'ETH', isETH: true, } as TokenI; +export const MOCK_USDC_ASSET = { + decimals: 6, + address: '0xUSDC000000000000000000000000000000000000', + chainId: '0x1', + balance: '200.9999 USDC', + balanceFiat: '$200.98', + name: 'USD Coin', + symbol: 'USD Coin', + ticker: 'USDC', + isETH: false, +} as TokenI; + export const MOCK_GET_POOLED_STAKES_API_RESPONSE: PooledStakes = { accounts: [ { diff --git a/app/components/UI/Stake/components/GasImpactModal/index.tsx b/app/components/UI/Stake/components/GasImpactModal/index.tsx index dabbaca7c5d..c2872241556 100644 --- a/app/components/UI/Stake/components/GasImpactModal/index.tsx +++ b/app/components/UI/Stake/components/GasImpactModal/index.tsx @@ -126,7 +126,7 @@ const GasImpactModal = ({ route }: GasImpactModalProps) => { - {strings('stake.gas_cost_impact_warning')} + {strings('stake.gas_cost_impact_warning', { percentOverDeposit: 30 })} { setHasSentViewingStakingRewardsMetric, ] = useState(false); - const networkName = useSelector(selectNetworkName); + const networkConfigurationByChainId = useSelector((state: RootState) => + selectNetworkConfigurationByChainId(state, asset.chainId as Hex), + ); const { isEligible: isEligibleForPooledStaking } = useStakingEligibility(); @@ -216,7 +219,7 @@ const StakingBalanceContent = ({ asset }: StakingBalanceProps) => { asset.chainId as Hex, asset.ticker ?? asset.symbol, )} - name={networkName} + name={networkConfigurationByChainId?.name} /> } > diff --git a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap index a3a8efd1247..1ae10dfbaae 100644 --- a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap +++ b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap @@ -16,7 +16,7 @@ exports[`StakingBalance render matches snapshot 1`] = ` "paddingVertical": 10, } } - testID="asset-ETH" + testID="asset-Ethereum" > ({ describe('Staking Earnings', () => { it('should render correctly', () => { const { toJSON, getByText } = renderWithProvider( - , + , { state: STATE_MOCK, }, @@ -78,6 +91,7 @@ describe('Staking Earnings', () => { expect(getByText(strings('stake.annual_rate'))).toBeDefined(); expect(getByText(strings('stake.lifetime_rewards'))).toBeDefined(); expect(getByText(strings('stake.estimated_annual_earnings'))).toBeDefined(); + expect(getByText(strings('stake.view_earnings_history'))).toBeDefined(); expect(toJSON()).toMatchSnapshot(); }); }); diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.constants.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.constants.ts new file mode 100644 index 00000000000..f39dd555f4d --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.constants.ts @@ -0,0 +1,9 @@ +import { DateRange } from './StakingEarningsTimePeriod/StakingEarningsTimePeriod.types'; + +export const EARNINGS_HISTORY_TIME_PERIOD_DEFAULT = DateRange.MONTHLY; +export const EARNINGS_HISTORY_DAYS_LIMIT = 730; +export const EARNINGS_HISTORY_CHART_BAR_LIMIT = { + [DateRange.DAILY]: 7, + [DateRange.MONTHLY]: 12, + [DateRange.YEARLY]: 2, +}; diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.test.tsx new file mode 100644 index 00000000000..f2bf3bd0a50 --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.test.tsx @@ -0,0 +1,332 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import StakingEarningsHistory from './StakingEarningsHistory'; +import useStakingEarningsHistory from '../../../hooks/useStakingEarningsHistory'; +import { MOCK_STAKED_ETH_ASSET } from '../../../__mocks__/mockData'; +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../../../util/test/accountsControllerTestUtils'; +import { backgroundState } from '../../../../../../util/test/initial-root-state'; +import { Hex } from '@metamask/smart-transactions-controller/dist/types'; +import { TokenI } from '../../../../Tokens/types'; + +jest.mock('../../../hooks/useStakingEarningsHistory'); +jest.mock('react-native-svg-charts', () => { + const reactNativeSvgCharts = jest.requireActual('react-native-svg-charts'); + return { + ...reactNativeSvgCharts, + Grid: () => <>, + }; +}); + +const mockInitialState = { + settings: {}, + engine: { + backgroundState: { + ...backgroundState, + AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, + CurrencyRateController: { + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionRate: 3363.79, + }, + }, + }, + TokenRatesController: { + marketData: { + '0x1': { + '0xUSDC000000000000000000000000000000000000': { + price: 0.0002990514020561363, + }, + }, + }, + }, + NetworkController: { + selectedNetworkClientId: 'selectedNetworkClientId', + networkConfigurationsByChainId: { + '0x1': { + nativeCurrency: 'ETH', + chainId: '0x1' as Hex, + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], + defaultRpcEndpointIndex: 0, + }, + }, + }, + }, + }, +}; + +describe('StakingEarningsHistory', () => { + beforeEach(() => { + (useStakingEarningsHistory as jest.Mock).mockReturnValue({ + earningsHistory: [ + { + dateStr: '2022-12-31', + dailyRewards: '442219562575615', + sumRewards: '442219562575615', + }, + { + dateStr: '2023-01-01', + dailyRewards: '542219562575615', + sumRewards: '984439125151230', + }, + ], + isLoading: false, + error: null, + }); + }); + + it('renders correctly with earnings history', () => { + const { getByText } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + expect(getByText('7D')).toBeTruthy(); + expect(getByText('M')).toBeTruthy(); + expect(getByText('Y')).toBeTruthy(); + expect(getByText('Lifetime earnings')).toBeTruthy(); + expect(getByText('0.00098 ETH')).toBeTruthy(); + expect(getByText('December')).toBeTruthy(); + expect(getByText('$1.49')).toBeTruthy(); + expect(getByText('+ 0.00044 ETH')).toBeTruthy(); + expect(getByText('January')).toBeTruthy(); + expect(getByText('$1.82')).toBeTruthy(); + expect(getByText('+ 0.00054 ETH')).toBeTruthy(); + }); + + it('renders correctly with trailing zero values', () => { + (useStakingEarningsHistory as jest.Mock).mockReturnValue({ + earningsHistory: [ + { + dateStr: '2022-11-02', + dailyRewards: '0', + sumRewards: '0', + }, + { + dateStr: '2022-12-31', + dailyRewards: '442219562575615', + sumRewards: '442219562575615', + }, + { + dateStr: '2023-01-01', + dailyRewards: '542219562575615', + sumRewards: '984439125151230', + }, + ], + isLoading: false, + error: null, + }); + + const { getByText, queryByText } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + expect(getByText('Lifetime earnings')).toBeTruthy(); + expect(getByText('0.00098 ETH')).toBeTruthy(); + expect(getByText('December')).toBeTruthy(); + expect(getByText('$1.49')).toBeTruthy(); + expect(getByText('+ 0.00044 ETH')).toBeTruthy(); + expect(getByText('January')).toBeTruthy(); + expect(getByText('$1.82')).toBeTruthy(); + expect(getByText('+ 0.00054 ETH')).toBeTruthy(); + expect(queryByText('November')).toBeFalsy(); + }); + + it('should render correctly with an erc20 token asset', () => { + (useStakingEarningsHistory as jest.Mock).mockReturnValue({ + earningsHistory: [ + { + dateStr: '2022-12-31', + dailyRewards: '100000000', + sumRewards: '100000000', + }, + { + dateStr: '2023-01-01', + dailyRewards: '300000000', + sumRewards: '400000000', + }, + ], + isLoading: false, + error: null, + }); + + const erc20Asset = { + address: '0xUSDC000000000000000000000000000000000000', + chainId: '0x1', + name: 'USD Coin', + symbol: 'USD Coin', + ticker: 'USDC', + isETH: false, + decimals: 6, + } as TokenI; + + const { getByText, queryByText } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + expect(getByText('Lifetime earnings')).toBeTruthy(); + expect(getByText('400 USDC')).toBeTruthy(); + expect(getByText('December')).toBeTruthy(); + expect(getByText('$100.59')).toBeTruthy(); + expect(getByText('+ 100 USDC')).toBeTruthy(); + expect(getByText('January')).toBeTruthy(); + expect(getByText('$301.78')).toBeTruthy(); + expect(getByText('+ 300 USDC')).toBeTruthy(); + expect(queryByText('November')).toBeFalsy(); + }); + + it('should render correctly when switching currency setting', () => { + (useStakingEarningsHistory as jest.Mock).mockReturnValue({ + earningsHistory: [ + { + dateStr: '2022-12-31', + dailyRewards: '100000000', + sumRewards: '100000000', + }, + { + dateStr: '2023-01-01', + dailyRewards: '300000000', + sumRewards: '400000000', + }, + ], + isLoading: false, + error: null, + }); + + const erc20Asset = { + address: '0xUSDC000000000000000000000000000000000000', + chainId: '0x1', + name: 'USD Coin', + symbol: 'USD Coin', + ticker: 'USDC', + isETH: false, + decimals: 6, + } as TokenI; + + const { getByText, queryByText } = renderWithProvider( + , + { + state: { + ...mockInitialState, + engine: { + ...mockInitialState.engine, + backgroundState: { + ...mockInitialState.engine.backgroundState, + CurrencyRateController: { + ...mockInitialState.engine.backgroundState + .CurrencyRateController, + currentCurrency: 'xlm', + currencyRates: { + ETH: { + conversionRate: 7683.22, + }, + }, + }, + }, + }, + }, + }, + ); + + expect(getByText('Lifetime earnings')).toBeTruthy(); + expect(getByText('400 USDC')).toBeTruthy(); + expect(getByText('December')).toBeTruthy(); + expect(getByText('229.77 XLM')).toBeTruthy(); + expect(getByText('+ 100 USDC')).toBeTruthy(); + expect(getByText('January')).toBeTruthy(); + expect(getByText('689.3 XLM')).toBeTruthy(); + expect(getByText('+ 300 USDC')).toBeTruthy(); + expect(queryByText('November')).toBeFalsy(); + }); + + it('renders correctly with inner and trailing zero values', () => { + (useStakingEarningsHistory as jest.Mock).mockReturnValue({ + earningsHistory: [ + { + dateStr: '2022-10-02', + dailyRewards: '0', + sumRewards: '0', + }, + { + dateStr: '2022-11-30', + dailyRewards: '442219562575615', + sumRewards: '442219562575615', + }, + { + dateStr: '2022-12-31', + dailyRewards: '0', + sumRewards: '442219562575615', + }, + { + dateStr: '2023-01-01', + dailyRewards: '542219562575615', + sumRewards: '984439125151230', + }, + ], + isLoading: false, + error: null, + }); + + const { getByText, queryByText } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + expect(getByText('Lifetime earnings')).toBeTruthy(); + expect(getByText('0.00098 ETH')).toBeTruthy(); + expect(getByText('November')).toBeTruthy(); + expect(getByText('$1.49')).toBeTruthy(); + expect(getByText('+ 0.00044 ETH')).toBeTruthy(); + expect(getByText('December')).toBeTruthy(); + expect(getByText('+ 0 ETH')).toBeTruthy(); + expect(getByText('$0')).toBeTruthy(); + expect(getByText('January')).toBeTruthy(); + expect(getByText('$1.82')).toBeTruthy(); + expect(getByText('+ 0.00054 ETH')).toBeTruthy(); + expect(queryByText('October')).toBeFalsy(); + }); + + it('calls onTimePeriodChange and updates the selected time period', () => { + const { getByText } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + const timePeriodButton7D = getByText('7D'); + fireEvent.press(timePeriodButton7D); + + expect(getByText('December 31')).toBeTruthy(); + expect(getByText('$1.49')).toBeTruthy(); + expect(getByText('+ 0.00044 ETH')).toBeTruthy(); + expect(getByText('January 1')).toBeTruthy(); + expect(getByText('$1.82')).toBeTruthy(); + expect(getByText('+ 0.00054 ETH')).toBeTruthy(); + + const timePeriodButtonY = getByText('Y'); + fireEvent.press(timePeriodButtonY); + + expect(getByText('2022')).toBeTruthy(); + expect(getByText('$1.49')).toBeTruthy(); + expect(getByText('+ 0.00044 ETH')).toBeTruthy(); + expect(getByText('2023')).toBeTruthy(); + expect(getByText('$1.82')).toBeTruthy(); + expect(getByText('+ 0.00054 ETH')).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.tsx new file mode 100644 index 00000000000..edd467066ca --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.tsx @@ -0,0 +1,307 @@ +import { BN } from 'ethereumjs-util'; +import React, { useMemo, useState } from 'react'; +import { View } from 'react-native'; +import useStakingEarningsHistory, { + EarningHistory, +} from '../../../hooks/useStakingEarningsHistory'; +import { StakingEarningsHistoryChart } from './StakingEarningsHistoryChart/StakingEarningsHistoryChart'; +import { ChainId } from '@metamask/controller-utils'; +import { useSelector } from 'react-redux'; +import { + selectCurrencyRates, + selectCurrentCurrency, +} from '../../../../../../selectors/currencyRateController'; +import { selectNetworkConfigurations } from '../../../../../../selectors/networkController'; +import { selectTokenMarketData } from '../../../../../../selectors/tokenRatesController'; +import { Hex } from '../../../../../../util/smart-transactions/smart-publish-hook'; +import { + EarningsHistoryData, + StakingEarningsHistoryProps, + TimePeriodGroupInfo, +} from './StakingEarningsHistory.types'; +import StakingEarningsHistoryList from './StakingEarningsHistoryList/StakingEarningsHistoryList'; +import TimePeriodButtonGroup from './StakingEarningsTimePeriod/StakingEarningsTimePeriod'; +import { + EARNINGS_HISTORY_CHART_BAR_LIMIT, + EARNINGS_HISTORY_DAYS_LIMIT, + EARNINGS_HISTORY_TIME_PERIOD_DEFAULT, +} from './StakingEarningsHistory.constants'; +import { DateRange } from './StakingEarningsTimePeriod/StakingEarningsTimePeriod.types'; +import { + fillGapsInEarningsHistory, + formatRewardsFiat, + formatRewardsNumber, + formatRewardsWei, + getEntryTimePeriodGroupInfo, +} from './StakingEarningsHistory.utils'; + +const StakingEarningsHistory = ({ asset }: StakingEarningsHistoryProps) => { + const [selectedTimePeriod, setSelectedTimePeriod] = useState( + EARNINGS_HISTORY_TIME_PERIOD_DEFAULT, + ); + const currentCurrency: string = useSelector(selectCurrentCurrency); + const multiChainMarketData = useSelector(selectTokenMarketData); + const multiChainCurrencyRates = useSelector(selectCurrencyRates); + const networkConfigurations = useSelector(selectNetworkConfigurations); + const { + earningsHistory, + isLoading: isLoadingEarningsHistory, + error: errorEarningsHistory, + } = useStakingEarningsHistory({ + chainId: asset.chainId as ChainId, + limitDays: EARNINGS_HISTORY_DAYS_LIMIT, + }); + + const ticker = asset.ticker ?? asset.symbol; + // get exchange rates for asset chainId + const exchangeRates = multiChainMarketData[asset.chainId as Hex]; + let exchangeRate = 0; + if (exchangeRates) { + exchangeRate = exchangeRates[asset.address as Hex]?.price; + } + // attempt to find native currency for asset chainId + const nativeCurrency = + networkConfigurations[asset.chainId as Hex]?.nativeCurrency; + let conversionRate = 0; + // if native currency is found, use it to get conversion rate + if (nativeCurrency) { + conversionRate = + multiChainCurrencyRates[nativeCurrency]?.conversionRate ?? conversionRate; + } + + const transformedEarningsHistory = useMemo( + () => + fillGapsInEarningsHistory(earningsHistory, EARNINGS_HISTORY_DAYS_LIMIT), + [earningsHistory], + ); + + const { earningsHistoryChartData, earningsHistoryListData } = useMemo(() => { + const historyData: EarningsHistoryData = { + earningsHistoryChartData: { + earnings: [], + earningsTotal: '0', + ticker, + }, + earningsHistoryListData: [], + }; + + if ( + isLoadingEarningsHistory || + errorEarningsHistory || + !transformedEarningsHistory || + transformedEarningsHistory.length === 0 + ) + return historyData; + + const barLimit = EARNINGS_HISTORY_CHART_BAR_LIMIT[selectedTimePeriod]; + let rewardsTotalForChartTimePeriodBN = new BN(0); + let rewardsTotalForListTimePeriodBN = new BN(0); + let trailingZeroHistoryListValues = 0; + let currentTimePeriodChartGroup: string | null = null; + let currentTimePeriodListGroup: string | null = null; + let lastEntryTimePeriodGroupInfo: TimePeriodGroupInfo = { + dateStr: '', + chartGroup: '', + chartGroupLabel: '', + listGroup: '', + listGroupLabel: '', + listGroupHeader: '', + }; + let prevLastEntryTimePeriodGroupInfo: TimePeriodGroupInfo = { + dateStr: '', + chartGroup: '', + chartGroupLabel: '', + listGroup: '', + listGroupLabel: '', + listGroupHeader: '', + }; + + // update earnings total from last sumRewards key + const updateEarningsTotal = (entry: EarningHistory) => { + historyData.earningsHistoryChartData.earningsTotal = formatRewardsWei( + entry.sumRewards, + asset, + ); + }; + + // handles chart specific data per entry + const handleChartData = (entry: EarningHistory) => { + const rewardsBN = new BN(entry.dailyRewards); + const { chartGroup: newChartGroup } = lastEntryTimePeriodGroupInfo; + // add rewards to total for time period + if (currentTimePeriodChartGroup === newChartGroup) { + rewardsTotalForChartTimePeriodBN = + rewardsTotalForChartTimePeriodBN.add(rewardsBN); + } else { + historyData.earningsHistoryChartData.earnings.unshift({ + value: parseFloat( + formatRewardsWei( + rewardsTotalForChartTimePeriodBN.toString(), + asset, + true, + ), + ), + label: prevLastEntryTimePeriodGroupInfo.chartGroupLabel, + }); + // update current time period group + currentTimePeriodChartGroup = newChartGroup; + // reset for next time period + rewardsTotalForChartTimePeriodBN = new BN(rewardsBN); + } + }; + + // handles list specific data per entry + const handleListData = (entry: EarningHistory) => { + const rewardsBN = new BN(entry.dailyRewards); + const { listGroup: newListGroup } = lastEntryTimePeriodGroupInfo; + if (currentTimePeriodListGroup === newListGroup) { + rewardsTotalForListTimePeriodBN = + rewardsTotalForListTimePeriodBN.add(rewardsBN); + } else { + if (!rewardsTotalForListTimePeriodBN.gt(new BN(0))) { + trailingZeroHistoryListValues++; + } else { + trailingZeroHistoryListValues = 0; + } + historyData.earningsHistoryListData.push({ + label: prevLastEntryTimePeriodGroupInfo.listGroupLabel, + groupLabel: prevLastEntryTimePeriodGroupInfo.chartGroupLabel, + groupHeader: prevLastEntryTimePeriodGroupInfo.listGroupHeader, + amount: formatRewardsWei(rewardsTotalForListTimePeriodBN, asset), + amountSecondaryText: formatRewardsFiat( + rewardsTotalForListTimePeriodBN, + asset, + currentCurrency, + conversionRate, + exchangeRate, + ), + ticker, + }); + + // reset for next time period + currentTimePeriodListGroup = newListGroup; + rewardsTotalForListTimePeriodBN = new BN(rewardsBN); + } + }; + + const handleListTrailingZeros = () => { + if (trailingZeroHistoryListValues > 0) { + historyData.earningsHistoryListData.splice( + historyData.earningsHistoryListData.length - + trailingZeroHistoryListValues, + trailingZeroHistoryListValues, + ); + } + }; + + const finalizeListData = () => { + if (historyData.earningsHistoryChartData.earnings.length < barLimit) { + if (!rewardsTotalForListTimePeriodBN.gt(new BN(0))) { + trailingZeroHistoryListValues++; + } else { + trailingZeroHistoryListValues = 0; + } + historyData.earningsHistoryListData.push({ + label: lastEntryTimePeriodGroupInfo.listGroupLabel, + groupLabel: lastEntryTimePeriodGroupInfo.chartGroupLabel, + groupHeader: lastEntryTimePeriodGroupInfo.listGroupHeader, + amount: formatRewardsWei(rewardsTotalForListTimePeriodBN, asset), + amountSecondaryText: formatRewardsFiat( + rewardsTotalForListTimePeriodBN, + asset, + currentCurrency, + conversionRate, + exchangeRate, + ), + ticker, + }); + } + // removes trailing zeros from history list + handleListTrailingZeros(); + }; + + const finalizeChartData = () => { + if (historyData.earningsHistoryChartData.earnings.length < barLimit) { + historyData.earningsHistoryChartData.earnings.unshift({ + value: parseFloat( + formatRewardsWei( + rewardsTotalForChartTimePeriodBN.toString(), + asset, + true, + ), + ), + label: lastEntryTimePeriodGroupInfo.chartGroupLabel, + }); + } + }; + + const finalizeProcessing = () => { + finalizeListData(); + finalizeChartData(); + }; + + const processEntry = (entry: EarningHistory, i: number) => { + if (i === transformedEarningsHistory.length - 1) { + updateEarningsTotal(entry); + } + prevLastEntryTimePeriodGroupInfo = { + ...lastEntryTimePeriodGroupInfo, + }; + lastEntryTimePeriodGroupInfo = getEntryTimePeriodGroupInfo( + entry.dateStr, + selectedTimePeriod, + ); + if (!currentTimePeriodChartGroup) { + currentTimePeriodChartGroup = lastEntryTimePeriodGroupInfo.chartGroup; + currentTimePeriodListGroup = lastEntryTimePeriodGroupInfo.listGroup; + } + if (historyData.earningsHistoryChartData.earnings.length < barLimit) { + handleChartData(entry); + handleListData(entry); + } + }; + + const processEntries = () => { + for (let i = transformedEarningsHistory.length - 1; i >= 0; i--) { + processEntry(transformedEarningsHistory[i], i); + } + finalizeProcessing(); + }; + + processEntries(); + + return historyData; + }, [ + selectedTimePeriod, + isLoadingEarningsHistory, + errorEarningsHistory, + transformedEarningsHistory, + asset, + ticker, + currentCurrency, + conversionRate, + exchangeRate, + ]); + + const onTimePeriodChange = (newTimePeriod: DateRange) => { + setSelectedTimePeriod(newTimePeriod); + }; + + return isLoadingEarningsHistory ? null : ( + + + formatRewardsNumber(value, asset)} + /> + + + ); +}; + +export default StakingEarningsHistory; diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.types.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.types.ts new file mode 100644 index 00000000000..ac7b345c38c --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.types.ts @@ -0,0 +1,25 @@ +import { TokenI } from '../../../../Tokens/types'; +import { StakingEarningsHistoryChartData } from './StakingEarningsHistoryChart/StakingEarningsHistoryChart.types'; +import { StakingEarningsHistoryListData } from './StakingEarningsHistoryList/StakingEarningsHistoryList.types'; + +export interface StakingEarningsHistoryProps { + asset: TokenI; +} + +export interface TimePeriodGroupInfo { + dateStr: string; + chartGroup: string; + chartGroupLabel: string; + listGroup: string; + listGroupLabel: string; + listGroupHeader: string; +} + +export interface EarningsHistoryData { + earningsHistoryChartData: { + earnings: StakingEarningsHistoryChartData[]; + earningsTotal: string; + ticker: string; + }; + earningsHistoryListData: StakingEarningsHistoryListData[]; +} diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.test.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.test.ts new file mode 100644 index 00000000000..5bba1a3c256 --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.test.ts @@ -0,0 +1,158 @@ +import { + MOCK_STAKED_ETH_ASSET, + MOCK_USDC_ASSET, +} from '../../../__mocks__/mockData'; +import { + getEntryTimePeriodGroupInfo, + fillGapsInEarningsHistory, + formatRewardsWei, + formatRewardsNumber, + formatRewardsFiat, +} from './StakingEarningsHistory.utils'; +import { DateRange } from './StakingEarningsTimePeriod/StakingEarningsTimePeriod.types'; + +const mockChartGroupDaily = { + dateStr: '2023-10-01', + chartGroup: '2023-10-01', + chartGroupLabel: 'October 1', + listGroup: '2023-10-01', + listGroupLabel: 'October 1', + listGroupHeader: '', +}; + +const mockChartGroupMonthly = { + dateStr: '2023-10-01', + chartGroup: '2023-10', + chartGroupLabel: 'October', + listGroup: '2023-10', + listGroupLabel: 'October', + listGroupHeader: '2023', +}; + +const mockChartGroupYearly = { + dateStr: '2023-10-01', + chartGroup: '2023', + chartGroupLabel: '2023', + listGroup: '2023', + listGroupLabel: '2023', + listGroupHeader: '', +}; + +describe('StakingEarningsHistory Utils', () => { + describe('getEntryTimePeriodGroupInfo', () => { + it('should return correct time period group info for daily', () => { + const result = getEntryTimePeriodGroupInfo( + mockChartGroupDaily.dateStr, + DateRange.DAILY, + ); + expect(result).toEqual(mockChartGroupDaily); + }); + + it('should return correct time period group info for monthly', () => { + const result = getEntryTimePeriodGroupInfo( + mockChartGroupMonthly.dateStr, + DateRange.MONTHLY, + ); + expect(result).toEqual(mockChartGroupMonthly); + }); + + it('should return correct time period group info for yearly', () => { + const result = getEntryTimePeriodGroupInfo( + mockChartGroupYearly.dateStr, + DateRange.YEARLY, + ); + expect(result).toEqual(mockChartGroupYearly); + }); + + it('should throw an error for unsupported time period', () => { + expect(() => + getEntryTimePeriodGroupInfo('2023-10-01', 'unsupported' as DateRange), + ).toThrow('Unsupported time period'); + }); + }); + + describe('fillGapsInEarningsHistory', () => { + it('should fill gaps in earnings history', () => { + const earningsHistory = [ + { dateStr: '2023-10-01', dailyRewards: '10', sumRewards: '10' }, + { dateStr: '2023-10-02', dailyRewards: '20', sumRewards: '30' }, + ]; + const result = fillGapsInEarningsHistory(earningsHistory, 5); + expect(result.length).toBe(5); + expect(result[0].dateStr).toBe('2023-09-28'); + expect(result[1].dateStr).toBe('2023-09-29'); + expect(result[2].dateStr).toBe('2023-09-30'); + }); + + it('should return an empty array if earnings history is null', () => { + const result = fillGapsInEarningsHistory(null, 5); + expect(result).toEqual([]); + }); + + it('should return an empty array if earnings history is empty', () => { + const result = fillGapsInEarningsHistory([], 5); + expect(result).toEqual([]); + }); + }); + + describe('formatRewardsWei', () => { + it('should format rewards value with special characters', () => { + const result = formatRewardsWei('1', MOCK_STAKED_ETH_ASSET); + expect(result).toBe('< 0.00001'); + }); + + it('should format rewards value with special characters when asset.isETH is false', () => { + const result = formatRewardsWei('1', MOCK_USDC_ASSET); + expect(result).toBe('< 0.00001'); + }); + + it('should format rewards value without special characters', () => { + const result = formatRewardsWei('1', MOCK_STAKED_ETH_ASSET, true); + expect(result).toBe('0.000000000000000001'); + }); + + it('should format rewards value without special characters when asset.isETH is false', () => { + const result = formatRewardsWei('1', MOCK_USDC_ASSET, true); + expect(result).toBe('0.000001'); + }); + }); + + describe('formatRewardsNumber', () => { + it('should format short rewards number correctly', () => { + const result = formatRewardsNumber(1.456, MOCK_STAKED_ETH_ASSET); + expect(result).toBe('1.456'); + }); + + it('should format long rewards number with 5 decimals', () => { + const result = formatRewardsNumber( + 1.456234265436536, + MOCK_STAKED_ETH_ASSET, + ); + expect(result).toBe('1.45623'); + }); + }); + + describe('formatRewardsFiat', () => { + it('should format rewards to fiat currency', () => { + const result = formatRewardsFiat( + '1000000000000000000', + MOCK_STAKED_ETH_ASSET, + 'usd', + 2000, + 1, + ); + expect(result).toBe('$2000'); + }); + + it('should format rewards to fiat currency when asset.isETH is false', () => { + const result = formatRewardsFiat( + '1000000', + MOCK_USDC_ASSET, + 'usd', + 2000, + 1, + ); + expect(result).toBe('$2000'); + }); + }); +}); diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.ts new file mode 100644 index 00000000000..0c305cca66b --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.ts @@ -0,0 +1,177 @@ +import { + balanceToFiatNumber, + renderFromTokenMinimalUnit, + renderFiat, + fromTokenMinimalUnit, + weiToFiatNumber, + renderFromWei, + fromWei, +} from '../../../../../../util/number'; +import { BN } from 'ethereumjs-util'; +import { TimePeriodGroupInfo } from './StakingEarningsHistory.types'; +import { DateRange } from './StakingEarningsTimePeriod/StakingEarningsTimePeriod.types'; +import BigNumber from 'bignumber.js'; +import { EarningHistory } from '../../../hooks/useStakingEarningsHistory'; +import { TokenI } from '../../../../Tokens/types'; + +/** + * Formats the date string into a time period group info object + * + * @param {string} dateStr - The date string YYYY-MM-DD to format. + * @param {DateRange} selectedTimePeriod - The selected time period. + * @returns {TimePeriodGroupInfo} The formatted time period group info object. + */ +export const getEntryTimePeriodGroupInfo = ( + dateStr: string, + selectedTimePeriod: DateRange, +): TimePeriodGroupInfo => { + const [newYear, newMonth] = dateStr.split('-'); + const date = new Date(dateStr); + date.setUTCHours(0, 0, 0, 0); + const timePeriodInfo: TimePeriodGroupInfo = { + dateStr, + chartGroup: '', + chartGroupLabel: '', + listGroup: '', + listGroupLabel: '', + listGroupHeader: '', + }; + const dayLabel = date.toLocaleString('fullwide', { + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }); + const monthLabel = date.toLocaleString('fullwide', { + month: 'long', + timeZone: 'UTC', + }); + const yearLabel = date.toLocaleString('fullwide', { + year: 'numeric', + timeZone: 'UTC', + }); + switch (selectedTimePeriod) { + case DateRange.DAILY: + timePeriodInfo.chartGroup = dateStr; + timePeriodInfo.chartGroupLabel = dayLabel; + timePeriodInfo.listGroup = dateStr; + timePeriodInfo.listGroupLabel = dayLabel; + break; + case DateRange.MONTHLY: + timePeriodInfo.chartGroup = `${newYear}-${newMonth}`; + timePeriodInfo.chartGroupLabel = monthLabel; + timePeriodInfo.listGroup = `${newYear}-${newMonth}`; + timePeriodInfo.listGroupLabel = monthLabel; + timePeriodInfo.listGroupHeader = newYear; + break; + case DateRange.YEARLY: + timePeriodInfo.chartGroup = newYear; + timePeriodInfo.chartGroupLabel = yearLabel; + timePeriodInfo.listGroup = newYear; + timePeriodInfo.listGroupLabel = yearLabel; + break; + default: + throw new Error('Unsupported time period'); + } + return timePeriodInfo; +}; + +/** + * Fills gaps in earnings history by adding zero values for days missing out of the limitDays + * + * @param {EarningHistory[] | null} earningsHistory - The earnings history to fill gaps in. + * @param {number} limitDays - The number of days to fill gaps for. + * @returns {EarningHistory[]} The filled earnings history. + */ +export const fillGapsInEarningsHistory = ( + earningsHistory: EarningHistory[] | null, + limitDays: number, +): EarningHistory[] => { + if (!earningsHistory?.length) return []; + const gapFilledEarningsHistory = [...earningsHistory]; + const earliestDate = new Date(earningsHistory[0].dateStr); + const daysToFill = limitDays - earningsHistory.length; + const gapDate = new Date(earliestDate); + gapDate.setUTCHours(0, 0, 0, 0); + for (let i = 0; i < daysToFill; i++) { + gapDate.setDate(gapDate.getDate() - 1); + gapFilledEarningsHistory.unshift({ + dateStr: gapDate.toISOString().split('T')[0], + dailyRewards: '0', + sumRewards: '0', + }); + } + return gapFilledEarningsHistory; +}; + +/** + * Formats the rewards value from minimal unit to string representation + * + * @param {number | string | BN} rewardsValue - The rewards value in minimal units. + * @param {TokenI} asset - The asset to format the rewards value for. + * @param {boolean} [isRemoveSpecialCharacters=false] - A flag indicating whether to remove special characters from the formatted output. + * @returns {string} The formatted rewards value as a string. + */ +export const formatRewardsWei = ( + rewardsValue: number | string | BN, + asset: TokenI, + isRemoveSpecialCharacters: boolean = false, +): string => { + if (!isRemoveSpecialCharacters) { + // return a string with possible special characters in display formatting + return asset.isETH + ? renderFromWei(rewardsValue) + : renderFromTokenMinimalUnit(rewardsValue, asset.decimals); + } + // return a string without special characters + return asset.isETH + ? fromWei(rewardsValue) + : fromTokenMinimalUnit(rewardsValue, asset.decimals); +}; + +/** + * Formats floating point number rewards value into a string representation + * + * @param {number} rewardsValue - The raw rewards value to format. + * @param {TokenI} asset - The asset to format the rewards value for. + * @returns {string} The formatted rewards value string. + */ +export const formatRewardsNumber = ( + rewardsValue: number, + asset: TokenI, +): string => { + const weiValue = new BN( + new BigNumber(rewardsValue) + .multipliedBy(new BigNumber(10).pow(asset.decimals)) + .toString(), + ); + return formatRewardsWei(weiValue, asset); +}; + +/** + * Formats the rewards amount into a fiat currency representation. + * + * @param {string | BN} rewardsValue - The amount of rewards to format in minimal units, which can be a string or a BigNumber (BN). + * @param {TokenI} asset - The asset to format the rewards value for. + * @param {string} currency - The currency symbol to convert to. + * @param {number} conversionRate - ETH to current currency conversion rate + * @param {number} exchangeRate - Asset to ETH conversion rate. + * @returns {string} The formatted fiat currency string. + */ +export const formatRewardsFiat = ( + rewardsValue: string | BN, + asset: TokenI, + currency: string, + conversionRate: number, + exchangeRate: number = 0, +): string => { + if (asset.isETH) { + const weiFiatNumber = weiToFiatNumber(new BN(rewardsValue), conversionRate); + return renderFiat(weiFiatNumber, currency, 2); + } + const balanceFiatNumber = balanceToFiatNumber( + renderFromTokenMinimalUnit(rewardsValue, asset.decimals), + conversionRate, + exchangeRate, + ); + return renderFiat(balanceFiatNumber, currency, 2); +}; diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.styles.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.styles.ts new file mode 100644 index 00000000000..2c46ad4d0aa --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.styles.ts @@ -0,0 +1,25 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + stakingEarningsHistoryChart: { + height: 75, + flex: 1, + paddingTop: 16, + paddingBottom: 16, + marginBottom: 4, + }, + stakingEarningsHistoryChartHeaderContainer: { + flex: 1, // Use flex to fill the available space + justifyContent: 'center', // Center vertically + alignItems: 'center', // Center horizontally + paddingTop: 16, + paddingBottom: 16, + }, + stakingEarningsHistoryChartContainer: { + flex: 1, + width: '100%', + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.test.tsx new file mode 100644 index 00000000000..36cb7e84c9f --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.test.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import { fireEvent, render, RenderResult } from '@testing-library/react-native'; +import { StakingEarningsHistoryChart } from './StakingEarningsHistoryChart'; +import { fireLayoutEvent } from '../../../../../../../util/testUtils/react-native-svg-charts'; + +jest.mock('react-native-svg-charts', () => { + const reactNativeSvgCharts = jest.requireActual('react-native-svg-charts'); // Get the actual Grid component + return { + ...reactNativeSvgCharts, + Grid: () => <>, + }; +}); + +const mockEarningsData = { + earnings: [ + { value: 1.0, label: 'Day 1' }, + { value: 3.0, label: 'Day 2' }, + { value: 2.0, label: 'Day 3' }, + ], + earningsTotal: '6.00000', + ticker: 'ETH', +}; + +const barChartComponent = ( + +); + +const renderChart = () => { + const chartContainer = render(barChartComponent); + fireLayoutEvent(chartContainer.root, { width: 500, height: 200 }); + const chart = chartContainer.getByTestId('earnings-history-chart-container') + .props.children.props.children[1].props.children.props; + return { + chartContainer, + chart, + }; +}; + +describe('StakingEarningsHistoryChart', () => { + let chartContainer: RenderResult; + let chart: RenderResult['root']; + + beforeEach(() => { + jest.clearAllMocks(); + ({ chartContainer, chart } = renderChart()); + }); + + it('renders correct initial state', async () => { + expect(chartContainer.getByText('Lifetime earnings')).toBeTruthy(); + expect(chartContainer.getByText('6.00000 ETH')).toBeTruthy(); + expect( + chartContainer.getByTestId('earning-history-chart-bar-0'), + ).toBeTruthy(); + expect( + chartContainer.getByTestId('earning-history-chart-bar-1'), + ).toBeTruthy(); + expect( + chartContainer.getByTestId('earning-history-chart-bar-2'), + ).toBeTruthy(); + expect( + chartContainer.getByTestId('earning-history-chart-line-0'), + ).toBeTruthy(); + expect( + chartContainer.getByTestId('earning-history-chart-line-1'), + ).toBeTruthy(); + expect( + chartContainer.getByTestId('earning-history-chart-line-2'), + ).toBeTruthy(); + expect(chart.data.length).toBe(mockEarningsData.earnings.length); + }); + + it('updates chart state when bar 1 is clicked', () => { + // click bar 1 + fireEvent( + chartContainer.getByTestId('earnings-history-chart'), + 'onTouchStart', + { + nativeEvent: { locationX: 50 }, + }, + ); + // expect bar 1 to be selected and highlighted on touch + expect(chartContainer.getByText('Day 1')).toBeTruthy(); + expect(chartContainer.getByText('1.00000 ETH')).toBeTruthy(); + expect(chart.data[0].svg.fill).toBe('#1c8234'); + // end touch bar 1 + fireEvent( + chartContainer.getByTestId('earnings-history-chart'), + 'onTouchEnd', + { + nativeEvent: { pageX: 50, pageY: 50 }, + }, + ); + // expect bar 1 to be selected and highlighted after touch end + expect(chartContainer.getByText('Day 1')).toBeTruthy(); + expect(chartContainer.getByText('1.00000 ETH')).toBeTruthy(); + expect(chart.data[0].svg.fill).toBe('#1c8234'); + }); + + it('updates chart state when bar 2 is clicked', async () => { + // click bar 2 + fireEvent( + chartContainer.getByTestId('earnings-history-chart'), + 'onTouchStart', + { + nativeEvent: { locationX: 250 }, + }, + ); + // expect bar 2 to be selected and highlighted on touch + expect(chartContainer.getByText('Day 2')).toBeTruthy(); + expect(chartContainer.getByText('3.00000 ETH')).toBeTruthy(); + expect(chart.data[1].svg.fill).toBe('#1c8234'); + // end touch bar 2 + fireEvent( + chartContainer.getByTestId('earnings-history-chart'), + 'onTouchEnd', + { + nativeEvent: { locationX: 250 }, + }, + ); + // expect bar 2 to be selected and highlighted after touch end + expect(chartContainer.getByText('Day 2')).toBeTruthy(); + expect(chartContainer.getByText('3.00000 ETH')).toBeTruthy(); + expect(chart.data[1].svg.fill).toBe('#1c8234'); + }); + + it('updates chart state when bar 3 is clicked', async () => { + // click bar 3 + fireEvent( + chartContainer.getByTestId('earnings-history-chart'), + 'onTouchStart', + { + nativeEvent: { locationX: 450 }, + }, + ); + expect(chartContainer.getByText('Day 3')).toBeTruthy(); + expect(chartContainer.getByText('2.00000 ETH')).toBeTruthy(); + expect(chart.data[2].svg.fill).toBe('#1c8234'); + // end touch bar 3 + fireEvent( + chartContainer.getByTestId('earnings-history-chart'), + 'onTouchEnd', + { + nativeEvent: { locationX: 450 }, + }, + ); + expect(chartContainer.getByText('Day 3')).toBeTruthy(); + expect(chartContainer.getByText('2.00000 ETH')).toBeTruthy(); + expect(chart.data[2].svg.fill).toBe('#1c8234'); + }); + + it('updates chart to initial state when selected bar is set unselected', async () => { + // click bar 3 + fireEvent( + chartContainer.getByTestId('earnings-history-chart'), + 'onTouchStart', + { + nativeEvent: { locationX: 450 }, + }, + ); + // end touch bar 3 + fireEvent( + chartContainer.getByTestId('earnings-history-chart'), + 'onTouchEnd', + { + nativeEvent: { locationX: 450 }, + }, + ); + // expect bar 3 to be selected and highlighted + expect(chart.data[2].svg.fill).toBe('#1c8234'); + // click again + fireEvent( + chartContainer.getByTestId('earnings-history-chart'), + 'onTouchStart', + { + nativeEvent: { locationX: 450 }, + }, + ); + // end touch bar 3 + fireEvent( + chartContainer.getByTestId('earnings-history-chart'), + 'onTouchEnd', + { + nativeEvent: { locationX: 450 }, + }, + ); + // expect bar 3 to be unselected and not highlighted + expect(chart.data[0].svg.fill).toBe('url(#bar-gradient)'); + expect(chart.data[1].svg.fill).toBe('url(#bar-gradient)'); + expect(chart.data[2].svg.fill).toBe('url(#bar-gradient)'); + // expect chart to be in initial state + expect(chartContainer.getByText('Lifetime earnings')).toBeTruthy(); + expect(chartContainer.getByText('6.00000 ETH')).toBeTruthy(); + }); + + it('renders to match snapshot', () => { + expect(chartContainer.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.tsx new file mode 100644 index 00000000000..1bc98f34392 --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.tsx @@ -0,0 +1,279 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { GestureResponderEvent, View, LayoutChangeEvent } from 'react-native'; +import { Defs, Line, LinearGradient, Stop } from 'react-native-svg'; +import { BarChart, Grid } from 'react-native-svg-charts'; +import { useStyles } from '../../../../../../../component-library/hooks'; +import { useTheme } from '../../../../../../../util/theme'; +import styleSheet from './StakingEarningsHistoryChart.styles'; +import Text, { + TextVariant, +} from '../../../../../../../component-library/components/Texts/Text'; +import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; +import { + HorizontalLinesProps, + StakingEarningsHistoryChartProps, +} from './StakingEarningsHistoryChart.types'; + +const HorizontalLines = ({ + x, + y, + height, + bandwidth: bandWidth, + data, + onBandWidthChange, + strokeColor, +}: HorizontalLinesProps) => { + useEffect(() => { + onBandWidthChange && onBandWidthChange(bandWidth ?? 0); + }, [bandWidth, onBandWidthChange]); + + const renderBarTopLines = useCallback(() => { + if (!x || !y || !height || !data || !bandWidth) return null; + + return data.map((item, index) => ( + + )); + }, [data, x, y, height, bandWidth, strokeColor]); + + return <>{renderBarTopLines()}; +}; + +export function StakingEarningsHistoryChart({ + earnings, + ticker, + earningsTotal, + formatValue = (value) => value.toFixed(5), + onSelectedEarning, +}: StakingEarningsHistoryChartProps): React.ReactElement { + //hooks + const { colors } = useTheme(); + const { styles } = useStyles(styleSheet, {}); + + // constants + const animate = false; + const barGradientId = 'bar-gradient'; + const barGradientStop1 = { + offset: '0%', + stopColor: colors.success.muted, + stopOpacity: 0, + }; + const barGradientStop2 = { + offset: '100%', + stopColor: colors.success.muted, + stopOpacity: 0.1, + }; + const spacingDefault = 0; + + //states + const [selectedBarAmount, setSelectedBarAmount] = useState( + null, + ); + const [lastOnSelectedEarningBarIndex, setLastOnSelectedEarningBarIndex] = + useState(-1); + const [lastToggledBarIndex, setLastToggledBarIndex] = useState(-1); + const [barToggle, setBarToggle] = useState(false); + const [selectedBarIndex, setSelectedBarIndex] = useState(-1); + const [selectedBarLabel, setSelectedBarLabel] = useState(null); + const [bandWidth, setBandWidth] = useState(0); + const [spacing, setSpacing] = useState(spacingDefault); + const [chartWidth, setChartWidth] = useState(0); + const [hoveredBarIndex, setHoveredBarIndex] = useState(-1); + const [transformedData, setTransformedData] = useState< + { + value: number; + label: string; + svg: { fill: string; testID: string }; + }[] + >([]); + + // functions + const updateBarHoveredBarIndex = useCallback( + (xHover: number) => { + if (!bandWidth || !chartWidth || !earnings.length) return; + const barWidthTotal = bandWidth * earnings.length; + const spacingTotal = chartWidth - barWidthTotal; + const estimateGapSize = spacingTotal + ? spacingTotal / (earnings.length - 1) + : 0; + const barSegment = Math.floor(xHover / (bandWidth + estimateGapSize)); + if (barSegment >= 0 && barSegment < earnings.length) { + setHoveredBarIndex(barSegment); + } else { + setHoveredBarIndex(-1); + } + }, + [bandWidth, chartWidth, earnings.length], + ); + const handleTouchEnd = () => { + setHoveredBarIndex(-1); + let overrideBarToggle = !!barToggle; + if (lastToggledBarIndex !== selectedBarIndex) { + overrideBarToggle = false; + } + if (!overrideBarToggle) { + setBarToggle(true); + setLastToggledBarIndex(selectedBarIndex); + } else { + setBarToggle(false); + if ( + lastToggledBarIndex !== -1 && + lastToggledBarIndex === selectedBarIndex + ) { + setSelectedBarIndex(-1); + setLastToggledBarIndex(-1); + } + } + }; + const handleTouch = (evt: GestureResponderEvent) => { + updateBarHoveredBarIndex(evt.nativeEvent.locationX); + }; + + // update bar fill color on index change + useEffect(() => { + setTransformedData((prev) => { + const newTransformedData = [...prev]; + newTransformedData.forEach((data, index) => { + if (index === selectedBarIndex) { + data.svg.fill = colors.success.default; + } else { + data.svg.fill = `url(#${barGradientId})`; + } + }); + return newTransformedData; + }); + }, [selectedBarIndex, colors.success.default]); + // if there is graph data or width change update all state + useEffect(() => { + if (earnings && earnings.length > 0) { + let newSpacing = spacingDefault; + if (earnings.length > 1) { + newSpacing = 0.1; + } + setSpacing(newSpacing); + const newTransformedData = earnings.map((value, index) => ({ + value: value.value, + label: value.label, + svg: { + fill: `url(#${barGradientId})`, + testID: `earning-history-chart-bar-${index}`, + }, + })); + setTransformedData(newTransformedData); + } + }, [earnings, chartWidth]); + // select what is hovered over + useEffect(() => { + if (hoveredBarIndex !== -1) { + setSelectedBarIndex(hoveredBarIndex); + } + }, [hoveredBarIndex]); + // main updates, time period earnings change, selected bar change + useEffect(() => { + if (selectedBarIndex !== -1 && selectedBarIndex < earnings.length) { + const newSelectedBarAmount = formatValue( + earnings[selectedBarIndex].value, + ); + const newSelectedBarLabel = earnings[selectedBarIndex].label; + setSelectedBarAmount(newSelectedBarAmount); + setSelectedBarLabel(newSelectedBarLabel); + } else { + setSelectedBarAmount(null); + setSelectedBarLabel(null); + } + if ( + onSelectedEarning && + lastOnSelectedEarningBarIndex !== selectedBarIndex + ) { + onSelectedEarning(earnings[selectedBarIndex]); + setLastOnSelectedEarningBarIndex(selectedBarIndex); + } + }, [ + selectedBarIndex, + earnings, + formatValue, + colors.success.default, + onSelectedEarning, + lastToggledBarIndex, + lastOnSelectedEarningBarIndex, + ]); + // reset bar toggle state when earnings array changes + useEffect(() => { + // deselect all bars each change + setSelectedBarIndex(-1); + setLastToggledBarIndex(-1); + setBarToggle(false); + }, [earnings]); + + return earnings ? ( + { + const { width } = event.nativeEvent.layout; + setChartWidth(width); + }} + testID={'earnings-history-chart-container'} + > + + + + {selectedBarAmount ?? earningsTotal} {ticker} + + + {selectedBarLabel ?? `Lifetime earnings`} + + + + item.value} + spacingInner={spacing} + spacingOuter={0} + > + + + + + + + + + + + + + ) : ( + + + + ); +} diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.types.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.types.ts new file mode 100644 index 00000000000..62ee051d95b --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.types.ts @@ -0,0 +1,26 @@ +export interface StakingEarningsHistoryChartData { + value: number; + label: string; +} + +export interface StakingEarningsHistoryChartProps { + earnings: StakingEarningsHistoryChartData[]; + ticker: string; + earningsTotal: string; + // callback to handle selected earning + onSelectedEarning?: (earning?: { value: number; label: string }) => void; + // format the graph value from parent + formatValue?: (value: number) => string; +} + +export interface HorizontalLinesProps { + // sends bandwidth to parent + onBandWidthChange?: (bandWidth: number) => void; + strokeColor: string; + // BarChart component props are passed into all children + x?: (number: number) => number; + y?: (number: number) => number; + height?: number; + bandwidth?: number; + data?: StakingEarningsHistoryChartProps['earnings']; +} diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/__snapshots__/StakingEarningsHistoryChart.test.tsx.snap b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/__snapshots__/StakingEarningsHistoryChart.test.tsx.snap new file mode 100644 index 00000000000..0f1c7f2cf19 --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/__snapshots__/StakingEarningsHistoryChart.test.tsx.snap @@ -0,0 +1,265 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StakingEarningsHistoryChart renders to match snapshot 1`] = ` + + + + + 6.00000 + + ETH + + + Lifetime earnings + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.styles.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.styles.ts new file mode 100644 index 00000000000..50277e0c15c --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.styles.ts @@ -0,0 +1,40 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + stakingEarningsHistoryListContainer: { + flex: 1, + paddingTop: 24, + paddingBottom: 35, + }, + lineItemContainer: { + flex: 1, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + paddingVertical: 8, + }, + rightLineItemContainer: { + flex: 1, + width: '50%', + justifyContent: 'space-between', + alignItems: 'flex-end', + }, + rightLineItemBox: { + flex: 1, + }, + leftLineItemBox: { + width: '50%', + justifyContent: 'center', + alignItems: 'flex-start', + flex: 1, + paddingTop: 10, + paddingBottom: 10, + }, + lineItemGroupHeaderContainer: { + paddingTop: 10, + paddingBottom: 10, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.test.tsx new file mode 100644 index 00000000000..7b32c416bf8 --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import StakingEarningsHistoryList from './StakingEarningsHistoryList'; + +describe('StakingEarningsHistoryList', () => { + const mockEarnings = [ + { + label: 'Reward 1', + amount: '1.0', + amountSecondaryText: '$3000.00', + groupLabel: 'Daily', + groupHeader: '2024', + ticker: 'ETH', + }, + { + label: 'Reward 2', + amount: '3.0', + amountSecondaryText: '$5000.00', + groupLabel: 'Daily', + groupHeader: '2024', + ticker: 'ETH', + }, + { + label: 'Reward 3', + amount: '2.0', + amountSecondaryText: '$6000.00', + groupLabel: 'Daily', + groupHeader: '2025', + ticker: 'ETH', + }, + ]; + + it('renders correctly with earnings data', () => { + const { getByText, getAllByText } = render( + , + ); + + // Check if each earning is displayed + mockEarnings.forEach((earning) => { + expect(getByText(earning.label)).toBeTruthy(); + expect(getByText(`+ ${earning.amount} ETH`)).toBeTruthy(); + expect(getByText(earning.amountSecondaryText)).toBeTruthy(); + expect(getAllByText(`2024`)).toHaveLength(1); + expect(getAllByText(`2025`)).toHaveLength(1); + }); + }); +}); diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.tsx new file mode 100644 index 00000000000..14617fccf6d --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.tsx @@ -0,0 +1,107 @@ +import React, { useCallback } from 'react'; +import { View } from 'react-native'; +import Label from '../../../../../../../component-library/components/Form/Label'; +import Text from '../../../../../../../component-library/components/Texts/Text'; +import { TextVariant } from '../../../../../../../component-library/components/Texts/Text/Text.types'; +import { useTheme } from '../../../../../../../util/theme'; +import styleSheet from './StakingEarningsHistoryList.styles'; +import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; +import { strings } from '../../../../../../../../locales/i18n'; +import { StakingEarningsHistoryListProps } from './StakingEarningsHistoryList.types'; + +const StakingEarningsHistoryList = ({ + earnings, + filterByGroupLabel, +}: StakingEarningsHistoryListProps) => { + const { colors } = useTheme(); + const styles = styleSheet(); + + const renderEarningsList = useCallback(() => { + let lastGroupHeader: string | null = null; + return earnings.map((earning, index) => { + const isFirstEarningInGroup = earning.groupHeader !== lastGroupHeader; + lastGroupHeader = earning.groupHeader; + const isGroupHeaderVisible = + earning.groupHeader.length > 0 && isFirstEarningInGroup; + if (!filterByGroupLabel || earning.groupLabel === filterByGroupLabel) { + return ( + + {isGroupHeaderVisible && ( + + + + )} + + + + + + + + + + + {earning.amountSecondaryText} + + + + + + ); + } + return null; + }); + }, [earnings, filterByGroupLabel, styles, colors]); + + const renderLoadingSkeleton = useCallback( + () => ( + + {Array.from({ length: 7 }).map((_, index) => ( + + ))} + + ), + [], + ); + + return ( + + {earnings ? ( + <> + + {renderEarningsList()} + + ) : ( + renderLoadingSkeleton() + )} + + ); +}; + +export default StakingEarningsHistoryList; diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.types.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.types.ts new file mode 100644 index 00000000000..acea05cbbe7 --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.types.ts @@ -0,0 +1,13 @@ +export interface StakingEarningsHistoryListProps { + earnings: StakingEarningsHistoryListData[]; + filterByGroupLabel?: string; +} + +export interface StakingEarningsHistoryListData { + label: string; + amount: string; + amountSecondaryText: string; + groupLabel: string; + groupHeader: string; + ticker: string; +} diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.styles.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.styles.ts new file mode 100644 index 00000000000..0c46050b59c --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.styles.ts @@ -0,0 +1,43 @@ +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({ + timePeriodButtonGroupContainer: { + flexDirection: 'row', + justifyContent: 'center', + paddingTop: 32, + paddingBottom: 32, + }, + unselectedButtonLabel: { + color: colors.text.alternative, + }, + selectedButtonLabel: { + color: colors.text.alternative, + }, + buttonContainer: { + marginLeft: 8, + marginRight: 8, + }, + button: { + backgroundColor: colors.background.default, + width: '100%', + borderRadius: 32, + paddingHorizontal: 14, + paddingVertical: 7, + }, + selectedButton: { + backgroundColor: colors.background.muted, + }, + buttonLabel: { + letterSpacing: 3, + textAlign: 'center', + color: colors.text.default, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.test.tsx new file mode 100644 index 00000000000..4c490eec6e3 --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.test.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import TimePeriodButtonGroup from './StakingEarningsTimePeriod'; +import { DateRange } from './StakingEarningsTimePeriod.types'; +describe('TimePeriodButtonGroup', () => { + const mockOnTimePeriodChange = jest.fn(); + + it('renders correctly and allows time period selection', () => { + const { getByText } = render( + , + ); + + // Check if the initial time period button is rendered + expect(getByText('M')).toBeTruthy(); + + // Simulate selecting a different time period + fireEvent.press(getByText('7D')); + + // Check if the onTimePeriodChange function is called with the correct argument + expect(mockOnTimePeriodChange).toHaveBeenCalledWith(DateRange.DAILY); + }); + + it('renders all time period options', () => { + const { getByText } = render( + , + ); + + // Check if all time period buttons are rendered + expect(getByText('7D')).toBeTruthy(); + expect(getByText('M')).toBeTruthy(); + expect(getByText('Y')).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.tsx new file mode 100644 index 00000000000..972e11870b2 --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.tsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import { TouchableOpacity, View } from 'react-native'; +import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; +import Text, { + TextVariant, +} from '../../../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../../../../component-library/hooks'; +import styleSheet from './StakingEarningsTimePeriod.styles'; +import { + DateRange, + TimePeriodButtonGroupProps, +} from './StakingEarningsTimePeriod.types'; + +const TimePeriodButtonGroup: React.FC = ({ + onTimePeriodChange = () => undefined, + initialTimePeriod, +}) => { + const [selectedButton, setSelectedButton] = useState( + () => initialTimePeriod, + ); + const [pressedButton, setPressedButton] = useState(null); + + const { styles } = useStyles(styleSheet, {}); + + const renderButton = (dateRange: DateRange, width: number) => { + const handlePress = () => { + setSelectedButton(dateRange); + onTimePeriodChange(dateRange); + }; + const handlePressIn = () => { + setPressedButton(dateRange); + }; + const handlePressOut = () => { + setPressedButton(null); + }; + + const isSelected = selectedButton === dateRange; + const isPressed = pressedButton === dateRange; + const labelStyle = + !isSelected && !isPressed + ? styles.unselectedButtonLabel + : styles.selectedButtonLabel; + const labelElement = ( + + {dateRange} + + ); + + const buttonSelectedStyle = !isSelected ? {} : styles.selectedButton; + const buttonStyle = { ...styles.button, ...buttonSelectedStyle }; + + return ( + + + + {labelElement} + + + + ); + }; + + return ( + + {initialTimePeriod ? ( + <> + {renderButton(DateRange.DAILY, 50)} + {renderButton(DateRange.MONTHLY, 45)} + {renderButton(DateRange.YEARLY, 42)} + + ) : ( + + + + )} + + ); +}; + +export default TimePeriodButtonGroup; diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.types.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.types.ts new file mode 100644 index 00000000000..216fcddfd32 --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.types.ts @@ -0,0 +1,10 @@ +export enum DateRange { + DAILY = '7D', + MONTHLY = 'M', + YEARLY = 'Y', +} + +export interface TimePeriodButtonGroupProps { + onTimePeriodChange?: (timePeriod: DateRange) => void; + initialTimePeriod: DateRange; +} diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.test.tsx new file mode 100644 index 00000000000..abeeb275305 --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import { WalletViewSelectorsIDs } from '../../../../../../../e2e/selectors/wallet/WalletView.selectors'; +import Routes from '../../../../../../constants/navigation/Routes'; +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { MOCK_STAKED_ETH_ASSET } from '../../../__mocks__/mockData'; +import StakingEarningsHistoryButton from './StakingEarningsHistoryButton'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + }), + }; +}); + +const renderComponent = () => + renderWithProvider( + , + ); + +describe('StakingEarningsHistoryButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + const { getByTestId } = renderComponent(); + expect( + getByTestId(WalletViewSelectorsIDs.STAKE_EARNINGS_HISTORY_BUTTON), + ).toBeDefined(); + }); + + it('navigates to Stake Rewards History screen when stake history button is pressed', async () => { + const { getByTestId } = renderComponent(); + + fireEvent.press( + getByTestId(WalletViewSelectorsIDs.STAKE_EARNINGS_HISTORY_BUTTON), + ); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { + screen: Routes.STAKING.EARNINGS_HISTORY, + params: { asset: MOCK_STAKED_ETH_ASSET }, + }); + }); + }); +}); diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.tsx new file mode 100644 index 00000000000..f8fe909fc8a --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.tsx @@ -0,0 +1,42 @@ +import { useNavigation } from '@react-navigation/native'; +import React from 'react'; +import { View } from 'react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import Button, { + ButtonVariants, + ButtonWidthTypes, +} from '../../../../../../component-library/components/Buttons/Button'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { TokenI } from '../../../../Tokens/types'; +import { WalletViewSelectorsIDs } from '../../../../../../../e2e/selectors/wallet/WalletView.selectors'; + +interface StakingEarningsHistoryButtonProps { + asset: TokenI; +} + +const StakingEarningsHistoryButton = ({ + asset, +}: StakingEarningsHistoryButtonProps) => { + const { navigate } = useNavigation(); + + const onViewEarningsHistoryPress = () => { + navigate('StakeScreens', { + screen: Routes.STAKING.EARNINGS_HISTORY, + params: { asset }, + }); + }; + + return ( + +