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();
+ });
+});