diff --git a/app/components/account/OwnedTokensCard.tsx b/app/components/account/OwnedTokensCard.tsx index a44f18a20..93a02f022 100644 --- a/app/components/account/OwnedTokensCard.tsx +++ b/app/components/account/OwnedTokensCard.tsx @@ -40,6 +40,7 @@ export function OwnedTokensCard({ address }: { address: string }) { const fetchAccountTokens = useFetchAccountOwnedTokens(); const refresh = () => fetchAccountTokens(pubkey); const [showDropdown, setDropdown] = React.useState(false); + const [tokenFilter, setTokenFilter] = React.useState(''); const display = useQueryDisplay(); // Fetch owned tokens @@ -47,12 +48,14 @@ export function OwnedTokensCard({ address }: { address: string }) { if (!ownedTokens) refresh(); }, [address]); // eslint-disable-line react-hooks/exhaustive-deps + const tokens = ownedTokens?.data?.tokens; + const filteredTokens = useFilteredTokens(tokens, tokenFilter); + const status = ownedTokens?.status; + if (ownedTokens === undefined) { return null; } - const { status } = ownedTokens; - const tokens = ownedTokens.data?.tokens; const fetching = status === FetchStatus.Fetching; if (fetching && (tokens === undefined || tokens.length === 0)) { return ; @@ -68,15 +71,24 @@ export function OwnedTokensCard({ address }: { address: string }) { return ; } const showLogos = tokens.some(t => t.logoURI !== undefined); + const columnCount = 2 + (display === 'detail' ? 1 : 0) + (showLogos ? 1 : 0); return ( <> {showDropdown &&
setDropdown(false)} />}
-
-

Token Holdings

- setDropdown(show => !show)} show={showDropdown} /> +
+

Token Holdings

+
+ +
+ setDropdown(show => !show)} show={showDropdown} /> +
+
@@ -89,14 +101,23 @@ export function OwnedTokensCard({ address }: { address: string }) { {display === 'detail' ? 'Total Balance' : 'Balance'} - {display === 'detail' ? ( - + {filteredTokens.length === 0 ? ( + + + + No tokens match this search + + + + ) : display === 'detail' ? ( + ) : ( - + )}
+ ); } @@ -293,3 +314,43 @@ const DisplayDropdown = ({ display, toggle, show }: DropdownProps) => {
); }; + +type TokenFilterInputProps = { + value: string; + onChange: (value: string) => void; +}; + +const TokenFilterInput = ({ value, onChange }: TokenFilterInputProps) => ( + onChange(event.target.value)} + /> +); + +function useFilteredTokens(tokens: TokenInfoWithPubkey[] | undefined, filter: string) { + return useMemo(() => { + const query = filter.trim().toLowerCase(); + if (!query) { + return tokens ?? []; + } + + return (tokens ?? []).filter(({ info, pubkey, symbol, name }) => { + const accountAddress = pubkey.toBase58().toLowerCase(); + const mintAddress = info.mint.toBase58().toLowerCase(); + const normalizedSymbol = symbol?.toLowerCase(); + const normalizedName = name?.toLowerCase(); + + return ( + mintAddress.includes(query) || + accountAddress.includes(query) || + (normalizedSymbol?.includes(query) ?? false) || + (normalizedName?.includes(query) ?? false) + ); + }); + }, [tokens, filter]); +} diff --git a/app/components/account/__tests__/OwnedTokensCard.spec.tsx b/app/components/account/__tests__/OwnedTokensCard.spec.tsx new file mode 100644 index 000000000..b49fffc67 --- /dev/null +++ b/app/components/account/__tests__/OwnedTokensCard.spec.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PublicKey } from '@solana/web3.js'; +import React from 'react'; +import { vi } from 'vitest'; + +import { OwnedTokensCard } from '@/app/components/account/OwnedTokensCard'; +import { FetchStatus } from '@/app/providers/cache'; +import type { TokenInfoWithPubkey } from '@/app/providers/accounts/tokens'; + +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(() => '/address/mock/tokens'), + useSearchParams: vi.fn(() => new URLSearchParams()), +})); + +vi.mock('next/link', () => ({ + __esModule: true, + default: ({ children, ...props }: React.ComponentProps<'a'>) => {children}, +})); + +vi.mock('next/image', () => ({ + __esModule: true, + default: ({ alt }: { alt: string }) => {alt}, +})); + +vi.mock('@components/common/Address', () => ({ + Address: ({ pubkey }: { pubkey: { toBase58: () => string } }) => {pubkey.toBase58()}, +})); + +vi.mock('@components/account/token-extensions/ScaledUiAmountMultiplierTooltip', () => ({ + __esModule: true, + default: () => null, +})); + +vi.mock('@providers/accounts/tokens', async () => { + const actual = await vi.importActual( + '@providers/accounts/tokens' + ); + return { + ...actual, + useAccountOwnedTokens: vi.fn(), + useFetchAccountOwnedTokens: vi.fn(), + useScaledUiAmountForMint: vi.fn(() => ['0', '1']), + }; +}); + +import { + useAccountOwnedTokens, + useFetchAccountOwnedTokens, +} from '@providers/accounts/tokens'; + +describe('OwnedTokensCard', () => { + const mockAddress = '7gN7aPfYZ7R6pujYnXghDUFxuDrGRq3NbS1hVeXeXzKZ'; + const solMint = new PublicKey('So11111111111111111111111111111111111111112'); + const bonkMint = new PublicKey('DezXAZ8z7PnrnRJgsSummSgGDn8uQmSez2w6zxDd4kPx'); + const mockTokens: TokenInfoWithPubkey[] = [ + { + info: { + isNative: false, + mint: solMint, + owner: new PublicKey('11111111111111111111111111111111'), + state: 'initialized', + tokenAmount: { + amount: '1000000000', + decimals: 9, + uiAmountString: '1', + }, + }, + name: 'Solana', + pubkey: new PublicKey('H3Y2Yk4EYBLETymFcpnSUsctNkYheAQ7EzxKQEiC5c6a'), + symbol: 'SOL', + }, + { + info: { + isNative: false, + mint: bonkMint, + owner: new PublicKey('11111111111111111111111111111111'), + state: 'initialized', + tokenAmount: { + amount: '500000', + decimals: 5, + uiAmountString: '5', + }, + }, + name: 'Bonk', + pubkey: new PublicKey('3h6sEBQZG9hV91bLbX6FVUFYttGjoHNJpX15HwNhEx27'), + symbol: 'BONK', + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useAccountOwnedTokens).mockReturnValue({ + data: { tokens: mockTokens }, + status: FetchStatus.Fetched, + }); + vi.mocked(useFetchAccountOwnedTokens).mockReturnValue(vi.fn()); + }); + + it('filters tokens using the find-as-you-type input', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Find token'); + await user.type(input, 'bonk'); + + expect(screen.getByText(bonkMint.toBase58())).toBeInTheDocument(); + expect(screen.queryByText(solMint.toBase58())).not.toBeInTheDocument(); + expect(screen.queryByText('No tokens match this search')).not.toBeInTheDocument(); + }); + + it('shows an empty state message when no tokens match the filter', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Find token'); + await user.type(input, 'missing-token'); + + expect(screen.getByText('No tokens match this search')).toBeInTheDocument(); + expect(screen.queryByText(solMint.toBase58())).not.toBeInTheDocument(); + expect(screen.queryByText(bonkMint.toBase58())).not.toBeInTheDocument(); + }); +});