Skip to content

Commit

Permalink
feat: show vault data in Earn popover (#1943)
Browse files Browse the repository at this point in the history
  • Loading branch information
dschlabach authored Feb 10, 2025
1 parent 3f0cee6 commit 19bd223
Show file tree
Hide file tree
Showing 16 changed files with 269 additions and 12 deletions.
4 changes: 4 additions & 0 deletions src/earn/components/DepositBalance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const baseContext: EarnContextType = {
refetchReceiptBalance: vi.fn(),
depositAmountError: null,
withdrawAmountError: null,
apy: 0,
nativeApy: 0,
vaultFee: 0,
rewards: [],
};

vi.mock('./EarnProvider', () => ({
Expand Down
4 changes: 4 additions & 0 deletions src/earn/components/DepositButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const baseContext: MakeRequired<EarnContextType, 'recipientAddress'> = {
refetchReceiptBalance: vi.fn(),
depositAmountError: null,
withdrawAmountError: null,
apy: 0,
nativeApy: 0,
vaultFee: 0,
rewards: [],
};

vi.mock('./EarnProvider', () => ({
Expand Down
131 changes: 129 additions & 2 deletions src/earn/components/DepositDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { EarnContextType } from '@/earn/types';
import { usdcToken } from '@/token/constants';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { Address } from 'viem';
import { type Mock, describe, expect, it, vi } from 'vitest';
import { DepositDetails } from './DepositDetails';
Expand All @@ -25,6 +25,10 @@ const baseContext: EarnContextType = {
refetchReceiptBalance: vi.fn(),
depositAmountError: null,
withdrawAmountError: null,
apy: 0.05,
nativeApy: 0.05,
vaultFee: 0.01,
rewards: [],
};
vi.mock('./EarnProvider', () => ({
useEarnContext: vi.fn(),
Expand All @@ -44,7 +48,6 @@ describe('DepositDetails Component', () => {
const tokenElement = screen.getByTestId('ockTokenChip_Button');
expect(tokenElement).toHaveTextContent(usdcToken.name);

// const tagElement = screen.getByTestId('tag');
expect(container).toHaveTextContent(`APY ${mockApy}`);
});

Expand All @@ -69,4 +72,128 @@ describe('DepositDetails Component', () => {
const earnDetails = screen.getByTestId('ockEarnDetails');
expect(earnDetails).toHaveClass(customClass);
});

it('renders native APY when value is provided', () => {
vi.mocked(useEarnContext).mockReturnValue({
...baseContext,
nativeApy: 0.05,
});

render(<DepositDetails />);

const trigger = screen.getByTestId('ock-apyInfoButton');
fireEvent.click(trigger);
expect(screen.getByTestId('ock-earnNativeApy')).toBeInTheDocument();
});

it('does not render native APY when value is undefined', () => {
vi.mocked(useEarnContext).mockReturnValue({
...baseContext,
nativeApy: undefined,
});

render(<DepositDetails />);
const trigger = screen.getByTestId('ock-apyInfoButton');
fireEvent.click(trigger);
expect(screen.queryByTestId('ock-earnNativeApy')).not.toBeInTheDocument();
});

it('renders rewards when value is provided', () => {
vi.mocked(useEarnContext).mockReturnValue({
...baseContext,
rewards: [{ assetName: 'MORPHO', apy: 0.05, asset: '0x123' as Address }],
});

render(<DepositDetails />);
const trigger = screen.getByTestId('ock-apyInfoButton');
fireEvent.click(trigger);
expect(screen.getByTestId('ock-earnRewards')).toBeInTheDocument();
});

it('does not render rewards when value is undefined', () => {
vi.mocked(useEarnContext).mockReturnValue({
...baseContext,
rewards: undefined,
});

render(<DepositDetails />);
expect(screen.queryByTestId('ock-earnRewards')).not.toBeInTheDocument();
});

it('renders performance fee when value is provided', () => {
vi.mocked(useEarnContext).mockReturnValue({
...baseContext,
vaultFee: 0.01,
});

render(<DepositDetails />);
const trigger = screen.getByTestId('ock-apyInfoButton');

fireEvent.click(trigger);

expect(screen.getByTestId('ock-earnPerformanceFee')).toBeInTheDocument();
});

it('does not render performance fee when value is falsey', () => {
vi.mocked(useEarnContext).mockReturnValue({
...baseContext,
vaultFee: 0,
});

render(<DepositDetails />);
const trigger = screen.getByTestId('ock-apyInfoButton');
fireEvent.click(trigger);
expect(
screen.queryByTestId('ock-earnPerformanceFee'),
).not.toBeInTheDocument();
});

// Popover tests
it('toggles popover visibility when info button is clicked', () => {
vi.mocked(useEarnContext).mockReturnValue({
...baseContext,
apy: 0.05,
});

render(<DepositDetails />);

const infoButton = screen.getByTestId('ock-apyInfoButton');

// Initial state - popover should be open (isOpen defaults to false)
expect(screen.queryByTestId('ock-earnNativeApy')).not.toBeInTheDocument();

// Click to open
fireEvent.click(infoButton);
expect(screen.getByTestId('ock-earnNativeApy')).toBeInTheDocument();

// Click to close
fireEvent.click(infoButton);
expect(screen.queryByTestId('ock-earnNativeApy')).not.toBeInTheDocument();
});

it('closes popover when onClose is triggered', () => {
vi.mocked(useEarnContext).mockReturnValue({
...baseContext,
apy: 0.05,
nativeApy: 0.05,
});

render(<DepositDetails />);

const trigger = screen.getByTestId('ock-apyInfoButton');

// Initial state - popover should not be open
expect(trigger).toBeInTheDocument();
expect(screen.queryByTestId('ock-earnNativeApy')).not.toBeInTheDocument();

// Click to open
fireEvent.click(trigger);
expect(screen.getByTestId('ock-earnNativeApy')).toBeInTheDocument();

// Trigger onClose
const popover = screen.getByRole('dialog');
fireEvent.keyDown(popover, { key: 'Escape' });

expect(screen.queryByTestId('ock-earnNativeApy')).not.toBeInTheDocument();
});
});
88 changes: 84 additions & 4 deletions src/earn/components/DepositDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,103 @@
import { getTruncatedAmount } from '@/earn/utils/getTruncatedAmount';
import { Popover } from '@/internal/components/Popover';
import { Skeleton } from '@/internal/components/Skeleton';
import { infoSvg } from '@/internal/svg/infoSvg';
import { formatPercent } from '@/internal/utils/formatPercent';
import { background } from '@/styles/theme';
import { cn, color, text } from '@/styles/theme';
import { background, border, cn, color, text } from '@/styles/theme';
import { useRef, useState } from 'react';
import type { DepositDetailsReact } from '../types';
import { EarnDetails } from './EarnDetails';
import { useEarnContext } from './EarnProvider';

function YieldInfo() {
const { rewards, nativeApy, vaultToken, vaultFee } = useEarnContext();
return (
<div
className={cn(
color.foregroundMuted,
border.defaultActive,
background.default,
'fade-in flex min-w-52 animate-in flex-col gap-2 rounded-lg border p-3 text-sm duration-200',
)}
>
{nativeApy ? (
<div
className="flex items-center justify-between gap-1"
data-testid="ock-earnNativeApy"
>
<div>{vaultToken?.symbol}</div>
<div className="font-semibold">{formatPercent(nativeApy)}</div>
</div>
) : null}

{rewards?.map((reward) => (
<div
key={reward.asset}
className="flex items-center justify-between gap-1"
data-testid="ock-earnRewards"
>
<div>{reward.assetName}</div>
<div className="font-semibold">{formatPercent(reward.apy)}</div>
</div>
))}

{vaultFee && nativeApy ? (
<div
className="flex items-center justify-between gap-1"
data-testid="ock-earnPerformanceFee"
>
<div>
Perf. Fee{' '}
<span className="text-xs">({formatPercent(vaultFee, 0)})</span>
</div>
<div className="font-semibold">
-{formatPercent(vaultFee * nativeApy)}
</div>
</div>
) : null}
</div>
);
}

function ApyTag({ apy }: { apy: number | undefined }) {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const anchorRef = useRef<HTMLDivElement>(null);
return apy ? (
<div
ref={anchorRef}
className={cn(
text.label1,
color.foregroundMuted,
background.alternate,
'flex items-center justify-center rounded-full p-1 px-3',
'flex items-center justify-center gap-1 rounded-full p-1 px-3',
)}
>
{`APY ${formatPercent(Number(getTruncatedAmount(apy.toString(), 3)))}`}
{`APY ${formatPercent(Number(getTruncatedAmount(apy.toString(), 4)))}`}
<button
ref={triggerRef}
type="button"
data-testid="ock-apyInfoButton"
className={cn(
'size-3 [&_path]:fill-[var(--ock-icon-color-foreground-muted)] [&_path]:transition-colors [&_path]:ease-in-out hover:[&_path]:fill-[var(--ock-icon-color-foreground)]',
isOpen && '[&_path]:fill-[var(--ock-icon-color-foreground)]',
)}
onClick={() => setIsOpen(!isOpen)}
>
{infoSvg}
</button>

<Popover
isOpen={isOpen}
onClose={() => setIsOpen(false)}
position="bottom"
align="end"
trigger={triggerRef}
anchor={anchorRef.current}
offset={4}
>
<YieldInfo />
</Popover>
</div>
) : (
<Skeleton className="!rounded-full h-7 min-w-28" />
Expand Down
4 changes: 4 additions & 0 deletions src/earn/components/EarnBalance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const baseContext: MakeRequired<EarnContextType, 'recipientAddress'> = {
refetchReceiptBalance: vi.fn(),
depositAmountError: null,
withdrawAmountError: null,
apy: 0,
nativeApy: 0,
vaultFee: 0,
rewards: [],
};

const queryClient = new QueryClient();
Expand Down
3 changes: 3 additions & 0 deletions src/earn/components/EarnDeposit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ const baseContext: MakeRequired<EarnContextType, 'recipientAddress'> = {
refetchReceiptBalance: vi.fn(),
depositAmountError: null,
withdrawAmountError: null,
nativeApy: 5,
vaultFee: 0,
rewards: [],
};

vi.mock('wagmi', async (importOriginal) => {
Expand Down
6 changes: 6 additions & 0 deletions src/earn/components/EarnProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export function EarnProvider({ vaultAddress, children }: EarnProviderReact) {
balanceStatus: receiptBalanceStatus,
refetchBalance: refetchReceiptBalance,
totalApy,
nativeApy,
vaultFee,
rewards,
} = useMorphoVault({
vaultAddress,
address,
Expand Down Expand Up @@ -148,6 +151,9 @@ export function EarnProvider({ vaultAddress, children }: EarnProviderReact) {
underlyingBalanceStatus,
refetchUnderlyingBalance,
apy: totalApy,
nativeApy,
vaultFee,
rewards,
// TODO: update when we have logic to fetch interest
interestEarned: '',
withdrawCalls,
Expand Down
4 changes: 4 additions & 0 deletions src/earn/components/EarnWithdraw.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const baseContext: MakeRequired<EarnContextType, 'recipientAddress'> = {
refetchReceiptBalance: vi.fn(),
depositAmountError: null,
withdrawAmountError: null,
apy: 0,
nativeApy: 0,
vaultFee: 0,
rewards: [],
};

vi.mock('wagmi', async (importOriginal) => {
Expand Down
4 changes: 4 additions & 0 deletions src/earn/components/WithdrawBalance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const baseContext: EarnContextType = {
refetchReceiptBalance: vi.fn(),
depositAmountError: null,
withdrawAmountError: null,
apy: 0,
nativeApy: 0,
vaultFee: 0,
rewards: [],
};

vi.mock('./EarnProvider', () => ({
Expand Down
4 changes: 4 additions & 0 deletions src/earn/components/WithdrawButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const baseContext: MakeRequired<EarnContextType, 'recipientAddress'> = {
refetchReceiptBalance: vi.fn(),
withdrawAmountError: null,
depositAmountError: null,
apy: 0,
nativeApy: 0,
vaultFee: 0,
rewards: [],
};

vi.mock('./EarnProvider', () => ({
Expand Down
4 changes: 4 additions & 0 deletions src/earn/components/WithdrawDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const baseContext: EarnContextType = {
refetchReceiptBalance: vi.fn(),
depositAmountError: null,
withdrawAmountError: null,
apy: 0,
nativeApy: 0,
vaultFee: 0,
rewards: [],
};

vi.mock('./EarnProvider', () => ({
Expand Down
Loading

0 comments on commit 19bd223

Please sign in to comment.