Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 69 additions & 8 deletions app/components/account/OwnedTokensCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,22 @@ 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
React.useEffect(() => {
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 <LoadingCard message="Loading token holdings" />;
Expand All @@ -68,15 +71,24 @@ export function OwnedTokensCard({ address }: { address: string }) {
return <ErrorCard text="Token holdings is not available for accounts with over 100 token accounts" />;
}
const showLogos = tokens.some(t => t.logoURI !== undefined);
const columnCount = 2 + (display === 'detail' ? 1 : 0) + (showLogos ? 1 : 0);

return (
<>
{showDropdown && <div className="dropdown-exit" onClick={() => setDropdown(false)} />}

<div className="card">
<div className="card-header align-items-center">
<h3 className="card-header-title">Token Holdings</h3>
<DisplayDropdown display={display} toggle={() => setDropdown(show => !show)} show={showDropdown} />
<div className="card-header align-items-center gap-2">
<h3 className="card-header-title mb-0">Token Holdings</h3>
<div
className="ms-auto d-flex align-items-center gap-2 flex-nowrap w-100"
style={{ maxWidth: '20rem' }}
>
<TokenFilterInput value={tokenFilter} onChange={setTokenFilter} />
<div className="flex-shrink-0">
<DisplayDropdown display={display} toggle={() => setDropdown(show => !show)} show={showDropdown} />
</div>
</div>
</div>

<div className="table-responsive mb-0">
Expand All @@ -89,14 +101,23 @@ export function OwnedTokensCard({ address }: { address: string }) {
<th className="text-muted">{display === 'detail' ? 'Total Balance' : 'Balance'}</th>
</tr>
</thead>
{display === 'detail' ? (
<HoldingsDetail tokens={tokens} showLogos={showLogos} />
{filteredTokens.length === 0 ? (
<tbody>
<tr>
<td colSpan={columnCount} className="text-center text-muted">
No tokens match this search
</td>
</tr>
</tbody>
) : display === 'detail' ? (
<HoldingsDetail tokens={filteredTokens} showLogos={showLogos} />
) : (
<HoldingsSummary tokens={tokens} showLogos={showLogos} />
<HoldingsSummary tokens={filteredTokens} showLogos={showLogos} />
)}
</table>
</div>
</div>

</>
);
}
Expand Down Expand Up @@ -293,3 +314,43 @@ const DisplayDropdown = ({ display, toggle, show }: DropdownProps) => {
</div>
);
};

type TokenFilterInputProps = {
value: string;
onChange: (value: string) => void;
};

const TokenFilterInput = ({ value, onChange }: TokenFilterInputProps) => (
<input
type="text"
className="form-control form-control-sm flex-grow-1"
style={{ minWidth: '5rem' }}
placeholder="Find token"
aria-label="Find token"
value={value}
onChange={event => 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]);
}
123 changes: 123 additions & 0 deletions app/components/account/__tests__/OwnedTokensCard.spec.tsx
Original file line number Diff line number Diff line change
@@ -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'>) => <a {...props}>{children}</a>,
}));

vi.mock('next/image', () => ({
__esModule: true,
default: ({ alt }: { alt: string }) => <img alt={alt} />,
}));

vi.mock('@components/common/Address', () => ({
Address: ({ pubkey }: { pubkey: { toBase58: () => string } }) => <span>{pubkey.toBase58()}</span>,
}));

vi.mock('@components/account/token-extensions/ScaledUiAmountMultiplierTooltip', () => ({
__esModule: true,
default: () => null,
}));

vi.mock('@providers/accounts/tokens', async () => {
const actual = await vi.importActual<typeof import('@providers/accounts/tokens')>(
'@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(<OwnedTokensCard address={mockAddress} />);

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(<OwnedTokensCard address={mockAddress} />);

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