diff --git a/app/components/account/HistoryCardComponents.tsx b/app/components/account/HistoryCardComponents.tsx index 056423320..5f7d6f4eb 100644 --- a/app/components/account/HistoryCardComponents.tsx +++ b/app/components/account/HistoryCardComponents.tsx @@ -1,6 +1,6 @@ import { ConfirmedSignatureInfo, TransactionError } from '@solana/web3.js'; import React from 'react'; -import { RefreshCw } from 'react-feather'; +import { Eye, EyeOff, RefreshCw } from 'react-feather'; export type TransactionRow = { slot: number; @@ -16,27 +16,52 @@ export function HistoryCardHeader({ title, refresh, fetching, + hideFailedTxs, + onToggleHideFailedTxs, }: { title: string; refresh: () => void; fetching: boolean; + hideFailedTxs?: boolean; + onToggleHideFailedTxs?: (value: boolean) => void; }) { return (

{title}

- )} - + +
); } diff --git a/app/components/account/__tests__/HistoryCardComponents.spec.tsx b/app/components/account/__tests__/HistoryCardComponents.spec.tsx new file mode 100644 index 000000000..5b6fcda1f --- /dev/null +++ b/app/components/account/__tests__/HistoryCardComponents.spec.tsx @@ -0,0 +1,288 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { getTransactionRows,HistoryCardFooter, HistoryCardHeader } from '../HistoryCardComponents'; + +describe('HistoryCardHeader', () => { + it('should render title and refresh button', () => { + const mockRefresh = vi.fn(); + + render(); + + expect(screen.getByText('Transaction History')).toBeInTheDocument(); + expect(screen.getByText('Refresh')).toBeInTheDocument(); + }); + + it('should call refresh when refresh button is clicked', async () => { + const user = userEvent.setup(); + const mockRefresh = vi.fn(); + + render(); + + const refreshButton = screen.getByRole('button', { name: /refresh/i }); + + await user.click(refreshButton); + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + + it('should disable refresh button when fetching', () => { + const mockRefresh = vi.fn(); + + render(); + + const refreshButton = screen.getByRole('button', { name: /loading/i }); + expect(refreshButton).toBeDisabled(); + }); + + it('should not render filter button when filter props are not provided', () => { + const mockRefresh = vi.fn(); + + render(); + + expect(screen.queryByText('Hide Failed')).not.toBeInTheDocument(); + expect(screen.queryByText('Show All')).not.toBeInTheDocument(); + }); + + it('should render "Hide Failed" button when hideFailedTxs is false', () => { + const mockRefresh = vi.fn(); + const mockToggle = vi.fn(); + + render( + + ); + + expect(screen.getByText('Hide Failed')).toBeInTheDocument(); + }); + + it('should render "Show All" button when hideFailedTxs is true', () => { + const mockRefresh = vi.fn(); + const mockToggle = vi.fn(); + + render( + + ); + + expect(screen.getByText('Show All')).toBeInTheDocument(); + }); + + it('should call onToggleHideFailedTxs with opposite value when filter button is clicked', async () => { + const user = userEvent.setup(); + const mockRefresh = vi.fn(); + const mockToggle = vi.fn(); + + render( + + ); + + const filterButton = screen.getByRole('button', { name: /hide failed/i }); + + await user.click(filterButton); + expect(mockToggle).toHaveBeenCalledTimes(1); + expect(mockToggle).toHaveBeenCalledWith(true); + }); + + it('should disable filter button when fetching', () => { + const mockRefresh = vi.fn(); + const mockToggle = vi.fn(); + + render( + + ); + + const filterButton = screen.getByRole('button', { name: /hide failed/i }); + expect(filterButton).toBeDisabled(); + }); + + it('should render both filter and refresh buttons when filter props are provided', () => { + const mockRefresh = vi.fn(); + const mockToggle = vi.fn(); + + render( + + ); + + expect(screen.getByText('Hide Failed')).toBeInTheDocument(); + expect(screen.getByText('Refresh')).toBeInTheDocument(); + }); +}); + +describe('HistoryCardFooter', () => { + it('should render "Load More" button when not at oldest', () => { + const mockLoadMore = vi.fn(); + + render(); + + expect(screen.getByText('Load More')).toBeInTheDocument(); + }); + + it('should render "Fetched full history" when at oldest', () => { + const mockLoadMore = vi.fn(); + + render(); + + expect(screen.getByText('Fetched full history')).toBeInTheDocument(); + expect(screen.queryByText('Load More')).not.toBeInTheDocument(); + }); + + it('should call loadMore when button is clicked', async () => { + const user = userEvent.setup(); + const mockLoadMore = vi.fn(); + + render(); + + const loadMoreButton = screen.getByRole('button', { name: /load more/i }); + + await user.click(loadMoreButton); + expect(mockLoadMore).toHaveBeenCalledTimes(1); + }); + + it('should disable "Load More" button when fetching', () => { + const mockLoadMore = vi.fn(); + + render(); + + const loadMoreButton = screen.getByRole('button', { name: /loading/i }); + expect(loadMoreButton).toBeDisabled(); + }); +}); + +describe('getTransactionRows', () => { + it('should return empty array for empty input', () => { + const result = getTransactionRows([]); + expect(result).toEqual([]); + }); + + it('should mark transaction with err as failed', () => { + const mockSignatures = [ + { + blockTime: 1234567890, + confirmationStatus: 'finalized' as const, + err: { InstructionError: [0, 'Custom error'] }, + memo: null, + signature: 'signature1', + slot: 100, + }, + ]; + + const result = getTransactionRows(mockSignatures); + + expect(result).toHaveLength(1); + expect(result[0].statusClass).toBe('warning'); + expect(result[0].statusText).toBe('Failed'); + expect(result[0].err).not.toBeNull(); + }); + + it('should mark transaction without err as success', () => { + const mockSignatures = [ + { + blockTime: 1234567890, + confirmationStatus: 'finalized' as const, + err: null, + memo: null, + signature: 'signature2', + slot: 101, + }, + ]; + + const result = getTransactionRows(mockSignatures); + + expect(result).toHaveLength(1); + expect(result[0].statusClass).toBe('success'); + expect(result[0].statusText).toBe('Success'); + expect(result[0].err).toBeNull(); + }); + + it('should process multiple transactions correctly', () => { + const mockSignatures = [ + { + blockTime: 1234567890, + confirmationStatus: 'finalized' as const, + err: null, + memo: null, + signature: 'signature1', + slot: 100, + }, + { + blockTime: 1234567891, + confirmationStatus: 'finalized' as const, + err: { InstructionError: [0, 'Custom error'] }, + memo: null, + signature: 'signature2', + slot: 101, + }, + { + blockTime: 1234567892, + confirmationStatus: 'finalized' as const, + err: null, + memo: null, + signature: 'signature3', + slot: 102, + }, + ]; + + const result = getTransactionRows(mockSignatures); + + expect(result).toHaveLength(3); + expect(result[0].statusClass).toBe('success'); + expect(result[1].statusClass).toBe('warning'); + expect(result[2].statusClass).toBe('success'); + }); + + it('should group transactions by slot', () => { + const mockSignatures = [ + { + blockTime: 1234567890, + confirmationStatus: 'finalized' as const, + err: null, + memo: null, + signature: 'signature1', + slot: 100, + }, + { + blockTime: 1234567890, + confirmationStatus: 'finalized' as const, + err: { InstructionError: [0, 'Custom error'] }, + memo: null, + signature: 'signature2', + slot: 100, + }, + ]; + + const result = getTransactionRows(mockSignatures); + + expect(result).toHaveLength(2); + expect(result[0].slot).toBe(100); + expect(result[1].slot).toBe(100); + }); +}); diff --git a/app/components/account/history/TransactionHistoryCard.tsx b/app/components/account/history/TransactionHistoryCard.tsx index 9043d2396..221651bd1 100644 --- a/app/components/account/history/TransactionHistoryCard.tsx +++ b/app/components/account/history/TransactionHistoryCard.tsx @@ -11,6 +11,8 @@ import { displayTimestampUtc } from '@utils/date'; import React, { useMemo } from 'react'; import Moment from 'react-moment'; +import { useHideFailedTransactions } from '@/app/hooks/useHideFailedTransactions'; + import { getTransactionRows, HistoryCardFooter, HistoryCardHeader } from '../HistoryCardComponents'; export function TransactionHistoryCard({ address }: { address: string }) { @@ -19,13 +21,15 @@ export function TransactionHistoryCard({ address }: { address: string }) { const fetchAccountHistory = useFetchAccountHistory(); const refresh = () => fetchAccountHistory(pubkey, false, true); const loadMore = () => fetchAccountHistory(pubkey, false); + const [hideFailedTxs, setHideFailedTxs] = useHideFailedTransactions(); const transactionRows = React.useMemo(() => { if (history?.data?.fetched) { - return getTransactionRows(history.data.fetched); + const rows = getTransactionRows(history.data.fetched); + return hideFailedTxs ? rows.filter(row => !row.err) : rows; } return []; - }, [history]); + }, [history, hideFailedTxs]); React.useEffect(() => { if (!history) { @@ -80,7 +84,13 @@ export function TransactionHistoryCard({ address }: { address: string }) { const fetching = history.status === FetchStatus.Fetching; return (
- refresh()} title="Transaction History" /> + refresh()} + title="Transaction History" + hideFailedTxs={hideFailedTxs} + onToggleHideFailedTxs={setHideFailedTxs} + />
diff --git a/app/hooks/__tests__/useHideFailedTransactions.spec.tsx b/app/hooks/__tests__/useHideFailedTransactions.spec.tsx new file mode 100644 index 000000000..719b98429 --- /dev/null +++ b/app/hooks/__tests__/useHideFailedTransactions.spec.tsx @@ -0,0 +1,118 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { useHideFailedTransactions } from '../useHideFailedTransactions'; + +const STORAGE_KEY = 'hideFailedTransactions'; + +describe('useHideFailedTransactions', () => { + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + }); + + afterEach(() => { + // Clean up after each test + localStorage.clear(); + }); + + it('should initialize with false when localStorage is empty', () => { + const { result } = renderHook(() => useHideFailedTransactions()); + const [hideFailedTxs] = result.current; + + expect(hideFailedTxs).toBe(false); + }); + + it('should initialize with true when localStorage has "true"', () => { + localStorage.setItem(STORAGE_KEY, 'true'); + + const { result } = renderHook(() => useHideFailedTransactions()); + const [hideFailedTxs] = result.current; + + expect(hideFailedTxs).toBe(true); + }); + + it('should initialize with false when localStorage has "false"', () => { + localStorage.setItem(STORAGE_KEY, 'false'); + + const { result } = renderHook(() => useHideFailedTransactions()); + const [hideFailedTxs] = result.current; + + expect(hideFailedTxs).toBe(false); + }); + + it('should persist state to localStorage when set to true', () => { + const { result } = renderHook(() => useHideFailedTransactions()); + const [, setHideFailedTxs] = result.current; + + act(() => { + setHideFailedTxs(true); + }); + + expect(localStorage.getItem(STORAGE_KEY)).toBe('true'); + expect(result.current[0]).toBe(true); + }); + + it('should persist state to localStorage when set to false', () => { + localStorage.setItem(STORAGE_KEY, 'true'); + + const { result } = renderHook(() => useHideFailedTransactions()); + const [, setHideFailedTxs] = result.current; + + act(() => { + setHideFailedTxs(false); + }); + + expect(localStorage.getItem(STORAGE_KEY)).toBe('false'); + expect(result.current[0]).toBe(false); + }); + + it('should toggle state correctly', () => { + const { result } = renderHook(() => useHideFailedTransactions()); + + // Initial state is false + expect(result.current[0]).toBe(false); + + // Toggle to true + act(() => { + result.current[1](true); + }); + expect(result.current[0]).toBe(true); + expect(localStorage.getItem(STORAGE_KEY)).toBe('true'); + + // Toggle to false + act(() => { + result.current[1](false); + }); + expect(result.current[0]).toBe(false); + expect(localStorage.getItem(STORAGE_KEY)).toBe('false'); + }); + + it('should preserve state across hook remounts', () => { + // First render + const { result: result1, unmount } = renderHook(() => useHideFailedTransactions()); + + act(() => { + result1.current[1](true); + }); + + expect(result1.current[0]).toBe(true); + + // Unmount and remount + unmount(); + const { result: result2 } = renderHook(() => useHideFailedTransactions()); + + // State should be preserved from localStorage + expect(result2.current[0]).toBe(true); + }); + + it('should handle invalid localStorage values gracefully', () => { + localStorage.setItem(STORAGE_KEY, 'invalid-value'); + + const { result } = renderHook(() => useHideFailedTransactions()); + const [hideFailedTxs] = result.current; + + // Should default to false for invalid values + expect(hideFailedTxs).toBe(false); + }); +}); diff --git a/app/hooks/useHideFailedTransactions.ts b/app/hooks/useHideFailedTransactions.ts new file mode 100644 index 000000000..7b79a2d52 --- /dev/null +++ b/app/hooks/useHideFailedTransactions.ts @@ -0,0 +1,42 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +const STORAGE_KEY = 'hideFailedTransactions'; + +/** + * Custom hook to manage the "hide failed transactions" filter preference. + * State is persisted to localStorage and survives page reloads. + * + * @returns [hideFailedTxs, setHideFailedTxs] - Current state and setter function + */ +export function useHideFailedTransactions(): [boolean, (value: boolean) => void] { + const [hideFailedTxs, setHideFailedTxsState] = useState(() => { + // Lazy initialization - read from localStorage on mount + if (typeof window === 'undefined') { + return false; // SSR safe default + } + + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored === 'true'; + } catch { + // Handle localStorage access errors gracefully + return false; + } + }); + + useEffect(() => { + // Sync state changes to localStorage + if (typeof window !== 'undefined') { + try { + localStorage.setItem(STORAGE_KEY, String(hideFailedTxs)); + } catch { + // Handle localStorage write errors gracefully (e.g., quota exceeded) + console.warn('Failed to save hideFailedTransactions preference to localStorage'); + } + } + }, [hideFailedTxs]); + + return [hideFailedTxs, setHideFailedTxsState]; +} \ No newline at end of file diff --git a/bench/BUILD.md b/bench/BUILD.md index 962d8a556..755ae22c0 100644 --- a/bench/BUILD.md +++ b/bench/BUILD.md @@ -1,32 +1,32 @@ | Type | Route | Size | First Load JS | |------|-------|------|---------------| -| Static | `/` | 15.6 kB | 1.01 MB | +| Static | `/` | 15.6 kB | 1 MB | | Static | `/_not-found` | 326 B | 157 kB | -| Dynamic | `/address/[address]` | 6.43 kB | 284 kB | -| Dynamic | `/address/[address]/anchor-account` | 5.76 kB | 987 kB | +| Dynamic | `/address/[address]` | 6.7 kB | 284 kB | +| Dynamic | `/address/[address]/anchor-account` | 5.75 kB | 984 kB | | Dynamic | `/address/[address]/anchor-program` | 326 B | 157 kB | -| Dynamic | `/address/[address]/attestation` | 5.78 kB | 967 kB | -| Dynamic | `/address/[address]/attributes` | 2.49 kB | 927 kB | -| Dynamic | `/address/[address]/blockhashes` | 1.88 kB | 921 kB | -| Dynamic | `/address/[address]/compression` | 4.74 kB | 953 kB | -| Dynamic | `/address/[address]/concurrent-merkle-tree` | 3.63 kB | 947 kB | -| Dynamic | `/address/[address]/domains` | 13.7 kB | 928 kB | -| Dynamic | `/address/[address]/entries` | 2.98 kB | 934 kB | -| Dynamic | `/address/[address]/feature-gate` | 325 B | 157 kB | -| Dynamic | `/address/[address]/idl` | 132 kB | 598 kB | -| Dynamic | `/address/[address]/instructions` | 2.11 kB | 1.03 MB | -| Dynamic | `/address/[address]/metadata` | 3.89 kB | 941 kB | -| Dynamic | `/address/[address]/nftoken-collection-nfts` | 5.84 kB | 967 kB | -| Dynamic | `/address/[address]/program-multisig` | 3.4 kB | 990 kB | -| Dynamic | `/address/[address]/rewards` | 3.47 kB | 925 kB | -| Dynamic | `/address/[address]/security` | 8.13 kB | 1.01 MB | -| Dynamic | `/address/[address]/slot-hashes` | 3.39 kB | 925 kB | -| Dynamic | `/address/[address]/stake-history` | 3.52 kB | 925 kB | -| Dynamic | `/address/[address]/token-extensions` | 8.46 kB | 986 kB | -| Dynamic | `/address/[address]/tokens` | 7.97 kB | 1.12 MB | -| Dynamic | `/address/[address]/transfers` | 3.52 kB | 1.06 MB | -| Dynamic | `/address/[address]/verified-build` | 5.94 kB | 992 kB | -| Dynamic | `/address/[address]/vote-history` | 3.4 kB | 925 kB | +| Dynamic | `/address/[address]/attestation` | 5.79 kB | 965 kB | +| Dynamic | `/address/[address]/attributes` | 2.48 kB | 924 kB | +| Dynamic | `/address/[address]/blockhashes` | 1.87 kB | 918 kB | +| Dynamic | `/address/[address]/compression` | 4.74 kB | 951 kB | +| Dynamic | `/address/[address]/concurrent-merkle-tree` | 3.63 kB | 945 kB | +| Dynamic | `/address/[address]/domains` | 13.8 kB | 925 kB | +| Dynamic | `/address/[address]/entries` | 2.97 kB | 931 kB | +| Dynamic | `/address/[address]/feature-gate` | 326 B | 157 kB | +| Dynamic | `/address/[address]/idl` | 132 kB | 597 kB | +| Dynamic | `/address/[address]/instructions` | 2.23 kB | 1.02 MB | +| Dynamic | `/address/[address]/metadata` | 3.89 kB | 938 kB | +| Dynamic | `/address/[address]/nftoken-collection-nfts` | 5.86 kB | 964 kB | +| Dynamic | `/address/[address]/program-multisig` | 3.39 kB | 987 kB | +| Dynamic | `/address/[address]/rewards` | 3.46 kB | 922 kB | +| Dynamic | `/address/[address]/security` | 8.2 kB | 1 MB | +| Dynamic | `/address/[address]/slot-hashes` | 3.37 kB | 922 kB | +| Dynamic | `/address/[address]/stake-history` | 3.51 kB | 922 kB | +| Dynamic | `/address/[address]/token-extensions` | 8.46 kB | 983 kB | +| Dynamic | `/address/[address]/tokens` | 7.96 kB | 1.12 MB | +| Dynamic | `/address/[address]/transfers` | 3.62 kB | 1.06 MB | +| Dynamic | `/address/[address]/verified-build` | 5.93 kB | 989 kB | +| Dynamic | `/address/[address]/vote-history` | 3.39 kB | 922 kB | | Dynamic | `/api/anchor` | 0 B | 0 B | | Dynamic | `/api/domain-info/[domain]` | 0 B | 0 B | | Static | `/api/metadata/proxy` | 0 B | 0 B | @@ -34,15 +34,15 @@ | Dynamic | `/api/programMetadataIdl` | 0 B | 0 B | | Dynamic | `/api/verified-programs/list/[page]` | 0 B | 0 B | | Dynamic | `/api/verified-programs/metadata/[programId]` | 0 B | 0 B | -| Dynamic | `/block/[slot]` | 10.3 kB | 938 kB | -| Dynamic | `/block/[slot]/accounts` | 4.46 kB | 918 kB | -| Dynamic | `/block/[slot]/programs` | 5.04 kB | 919 kB | -| Dynamic | `/block/[slot]/rewards` | 4.97 kB | 924 kB | -| Dynamic | `/epoch/[epoch]` | 6.77 kB | 257 kB | -| Static | `/feature-gates` | 3.35 kB | 923 kB | +| Dynamic | `/block/[slot]` | 10.3 kB | 935 kB | +| Dynamic | `/block/[slot]/accounts` | 4.49 kB | 915 kB | +| Dynamic | `/block/[slot]/programs` | 5.09 kB | 916 kB | +| Dynamic | `/block/[slot]/rewards` | 5.02 kB | 921 kB | +| Dynamic | `/epoch/[epoch]` | 6.83 kB | 257 kB | +| Static | `/feature-gates` | 3.38 kB | 921 kB | | Static | `/opengraph-image.png` | 0 B | 0 B | -| Static | `/supply` | 6.48 kB | 925 kB | -| Dynamic | `/tx/[signature]` | 37.5 kB | 1.39 MB | -| Dynamic | `/tx/[signature]/inspect` | 609 B | 1.19 MB | -| Static | `/tx/inspector` | 623 B | 1.19 MB | -| Static | `/verified-programs` | 6.09 kB | 165 kB | \ No newline at end of file +| Static | `/supply` | 6.52 kB | 922 kB | +| Dynamic | `/tx/[signature]` | 37.4 kB | 1.38 MB | +| Dynamic | `/tx/[signature]/inspect` | 605 B | 1.19 MB | +| Static | `/tx/inspector` | 618 B | 1.19 MB | +| Static | `/verified-programs` | 6.21 kB | 165 kB | \ No newline at end of file