From d2d7e74b4789e18cb01691f39a0b900a13ec6205 Mon Sep 17 00:00:00 2001 From: kishore08-07 Date: Tue, 28 Jul 2026 21:26:39 +0530 Subject: [PATCH] feat(wallet): implement transaction history feature - Add useQueryWalletHistory hook to fetch paginated transaction history from daemon - Add TxHistoryTable component to render sortable and expandable transaction rows - Add TxHistoryContent container for handling loading states and batch pagination - Add TxHistoryOverlay full-screen dialog for viewing complete history - Integrate inline Recent Activity section into MainWalletPage - Add translation keys - Add comprehensive unit tests Signed-off-by: kishore08-07 --- src/components/MainWalletPage.test.tsx | 4 + src/components/MainWalletPage.tsx | 11 + .../wallet/TxHistoryContent.test.tsx | 113 +++++++ src/components/wallet/TxHistoryContent.tsx | 72 +++++ .../wallet/TxHistoryOverlay.test.tsx | 66 ++++ src/components/wallet/TxHistoryOverlay.tsx | 36 +++ src/components/wallet/TxHistoryTable.test.tsx | 108 +++++++ src/components/wallet/TxHistoryTable.tsx | 300 ++++++++++++++++++ src/hooks/useQueryWalletHistory.test.ts | 72 +++++ src/hooks/useQueryWalletHistory.ts | 53 ++++ src/i18n/locales/en/translation.json | 21 ++ 11 files changed, 856 insertions(+) create mode 100644 src/components/wallet/TxHistoryContent.test.tsx create mode 100644 src/components/wallet/TxHistoryContent.tsx create mode 100644 src/components/wallet/TxHistoryOverlay.test.tsx create mode 100644 src/components/wallet/TxHistoryOverlay.tsx create mode 100644 src/components/wallet/TxHistoryTable.test.tsx create mode 100644 src/components/wallet/TxHistoryTable.tsx create mode 100644 src/hooks/useQueryWalletHistory.test.ts create mode 100644 src/hooks/useQueryWalletHistory.ts diff --git a/src/components/MainWalletPage.test.tsx b/src/components/MainWalletPage.test.tsx index e4661cee8..3f2da3734 100644 --- a/src/components/MainWalletPage.test.tsx +++ b/src/components/MainWalletPage.test.tsx @@ -53,6 +53,10 @@ vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({ ), })) +vi.mock('./wallet/TxHistoryContent', () => ({ + TxHistoryContent: () =>
, +})) + vi.mock('./ui/jam/Balance', () => ({ Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => ( {valueString} diff --git a/src/components/MainWalletPage.tsx b/src/components/MainWalletPage.tsx index 3dbcdc4a5..c8e4fbd84 100644 --- a/src/components/MainWalletPage.tsx +++ b/src/components/MainWalletPage.tsx @@ -19,6 +19,8 @@ import type { WalletFileName } from '@/lib/utils' import { cn, shortenStringMiddle, walletDisplayName } from '@/lib/utils' import { Balance } from './ui/jam/Balance' import { Spinner } from './ui/spinner' +import { TxHistoryContent } from './wallet/TxHistoryContent' +import { TxHistoryOverlay } from './wallet/TxHistoryOverlay' import { WalletJarsDetailsOverlay } from './wallet/WalletJarsDetailsOverlay' interface MainWalletPageProps { @@ -30,6 +32,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps) const navigate = useNavigate() const [selectedJar, setSelectedJar] = useState() const [isWalletJarsDetailsOpen, setIsWalletJarsDetailsOpen] = useState(false) + const [isTxHistoryOpen, setIsTxHistoryOpen] = useState(false) const { toggleDisplayMode } = useJamDisplayContext() const { isLoading, isFetching, error, refetch: refetchWalletData } = useJamWalletInfoContext() @@ -55,6 +58,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps) walletFileName={walletFileName} selectedJarIndex={selectedJar?.jarIndex} /> +

@@ -152,6 +156,13 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps) {t('global.refresh')}

+ setIsTxHistoryOpen(true)} + className="w-full max-w-6xl" + />
) diff --git a/src/components/wallet/TxHistoryContent.test.tsx b/src/components/wallet/TxHistoryContent.test.tsx new file mode 100644 index 000000000..b22a61a99 --- /dev/null +++ b/src/components/wallet/TxHistoryContent.test.tsx @@ -0,0 +1,113 @@ +import type { WalletHistoryResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' +import type { UseQueryResult } from '@tanstack/react-query' +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useQueryWalletHistory } from '@/hooks/useQueryWalletHistory' +import { TxHistoryContent } from './TxHistoryContent' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/hooks/useQueryWalletHistory', () => ({ + useQueryWalletHistory: vi.fn(), +})) + +vi.mock('./TxHistoryTable', () => ({ + TxHistoryTable: ({ history, compact }: { history: unknown[]; compact: boolean }) => ( +
+ Mock Table (Rows: {history.length}) +
+ ), +})) + +vi.mock('@/components/ui/alert', () => ({ + Alert: ({ children, variant }: { children: React.ReactNode; variant?: string }) => ( +
+ {children} +
+ ), + AlertDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/ui/spinner', () => ({ + Spinner: () =>
, +})) + +const mockUseQuery = useQueryWalletHistory as unknown as ReturnType + +const mockQueryResult = (overrides: Partial>) => + ({ + isLoading: false, + error: null, + isFetching: false, + refetch: vi.fn(), + ...overrides, + }) as UseQueryResult + +describe('TxHistoryContent', () => { + it('renders loading state', () => { + mockUseQuery.mockReturnValue({ + history: [], + queryResult: mockQueryResult({ isLoading: true }), + }) + + render() + expect(screen.getByTestId('mock-loading')).toBeInTheDocument() + }) + + it('renders error state', () => { + mockUseQuery.mockReturnValue({ + history: [], + queryResult: mockQueryResult({ error: new Error('failed') }), + }) + + render() + expect(screen.getByTestId('mock-alert')).toBeInTheDocument() + expect(screen.getByText('tx_history.error_loading')).toBeInTheDocument() + }) + + it('renders the table with data', () => { + const mockHistory = [{ txid: '1' }, { txid: '2' }] + mockUseQuery.mockReturnValue({ + history: mockHistory, + queryResult: mockQueryResult({}), + }) + + render() + expect(screen.getByTestId('mock-table')).toHaveAttribute('data-compact', 'true') + expect(screen.getByText('Mock Table (Rows: 2)')).toBeInTheDocument() + }) + + it('renders Load More button when not compact and handles clicks', () => { + const mockHistory = Array.from({ length: 10 }, () => ({ txid: 'mock' })) + const refetch = vi.fn() + + mockUseQuery.mockReturnValue({ + history: mockHistory, + queryResult: mockQueryResult({ refetch, isFetching: false }), + }) + + render() + + const loadMoreButton = screen.getByRole('button', { name: 'tx_history.button_load_more' }) + expect(loadMoreButton).toBeInTheDocument() + + fireEvent.click(loadMoreButton) + expect(mockUseQuery).toHaveBeenLastCalledWith(expect.objectContaining({ limit: 20 })) + }) + + it('hides Load More button if history length is less than limit', () => { + const mockHistory = Array.from({ length: 5 }, () => ({ txid: 'mock' })) + + mockUseQuery.mockReturnValue({ + history: mockHistory, + queryResult: mockQueryResult({}), + }) + + render() + expect(screen.queryByRole('button', { name: 'tx_history.button_load_more' })).not.toBeInTheDocument() + }) +}) diff --git a/src/components/wallet/TxHistoryContent.tsx b/src/components/wallet/TxHistoryContent.tsx new file mode 100644 index 000000000..aa64a5c26 --- /dev/null +++ b/src/components/wallet/TxHistoryContent.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react' +import { AlertTriangleIcon, ListIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Spinner } from '@/components/ui/spinner' +import { useQueryWalletHistory } from '@/hooks/useQueryWalletHistory' +import { cn, type WalletFileName } from '@/lib/utils' +import { TxHistoryTable } from './TxHistoryTable' + +interface TxHistoryContentProps { + walletFileName: WalletFileName + className?: string + initialLimit?: number + compact?: boolean + enabled?: boolean + onViewAll?: () => void +} + +export const TxHistoryContent = ({ + walletFileName, + className, + initialLimit = 10, + compact = false, + enabled = true, + onViewAll, +}: TxHistoryContentProps) => { + const { t } = useTranslation() + const [limit, setLimit] = useState(initialLimit) + const { history, queryResult } = useQueryWalletHistory({ walletFileName, limit, enabled }) + + return ( +
+
+

{t('tx_history.section_title')}

+ {compact && onViewAll ? ( + + ) : null} +
+ + {queryResult.isLoading ? ( +
+ + {t('global.loading')} +
+ ) : queryResult.error ? ( + + + {t('tx_history.error_loading')} + + ) : ( + + )} + + {!compact && history.length >= limit ? ( +
+ +
+ ) : null} +
+ ) +} diff --git a/src/components/wallet/TxHistoryOverlay.test.tsx b/src/components/wallet/TxHistoryOverlay.test.tsx new file mode 100644 index 000000000..f2f441949 --- /dev/null +++ b/src/components/wallet/TxHistoryOverlay.test.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { TxHistoryOverlay } from './TxHistoryOverlay' + +type ChildrenProps = { children: ReactNode } +type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void } + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('./TxHistoryContent', () => ({ + TxHistoryContent: ({ walletFileName }: { walletFileName: string }) => ( +
+ Content +
+ ), +})) + +vi.mock('../ui/jam/PageTitle', () => ({ + default: ({ title }: { title: string }) =>

{title}

, +})) + +// Mock Dialog to avoid dealing with portals and radix UI internals +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children, open, onOpenChange }: DialogProps) => + open ? ( +
+ + {children} +
+ ) : null, + DialogContent: ({ children }: ChildrenProps) =>
{children}
, + DialogHeader: ({ children }: ChildrenProps) =>
{children}
, + DialogTitle: ({ children }: ChildrenProps) =>
{children}
, +})) + +describe('TxHistoryOverlay', () => { + it('renders dialog content when open', () => { + render() + + expect(screen.getByTestId('page-title')).toHaveTextContent('tx_history.overlay_title') + + const content = screen.getByTestId('tx-history-content') + expect(content).toBeInTheDocument() + expect(content).toHaveAttribute('data-wallet', 'test-wallet.jmdat') + }) + + it('calls onOpenChange when closed', () => { + const onOpenChange = vi.fn() + render() + + const closeButton = screen.getByText('Close') + fireEvent.click(closeButton) + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/wallet/TxHistoryOverlay.tsx b/src/components/wallet/TxHistoryOverlay.tsx new file mode 100644 index 000000000..155c6fd09 --- /dev/null +++ b/src/components/wallet/TxHistoryOverlay.tsx @@ -0,0 +1,36 @@ +import type { ComponentProps } from 'react' +import { useTranslation } from 'react-i18next' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import type { WithRequiredProperty } from '@/types/global' +import PageTitle from '../ui/jam/PageTitle' +import { TxHistoryContent } from './TxHistoryContent' + +type TxHistoryOverlayProps = WithRequiredProperty< + Omit, 'children'>, + 'open' | 'onOpenChange' +> & + Pick, 'walletFileName'> + +export function TxHistoryOverlay({ open, onOpenChange, walletFileName, ...dialogProps }: TxHistoryOverlayProps) { + const { t } = useTranslation() + + return ( + onOpenChange(false)} {...dialogProps}> + + + + + + + +
+ +
+
+
+ ) +} diff --git a/src/components/wallet/TxHistoryTable.test.tsx b/src/components/wallet/TxHistoryTable.test.tsx new file mode 100644 index 000000000..6973ca82d --- /dev/null +++ b/src/components/wallet/TxHistoryTable.test.tsx @@ -0,0 +1,108 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { HistoryEntry } from '@/hooks/useQueryWalletHistory' +import { TxHistoryTable } from './TxHistoryTable' + +const mocks = vi.hoisted(() => ({ + toastSuccess: vi.fn(), + toastError: vi.fn(), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('sonner', () => ({ + toast: { + success: mocks.toastSuccess, + error: mocks.toastError, + }, +})) + +vi.mock('../ui/jam/Address', () => ({ + Address: ({ value }: { value: string }) => {value}, +})) + +vi.mock('../ui/jam/Balance', () => ({ + Balance: ({ valueString }: { valueString: string }) => {valueString}, +})) + +const mockDeposit: HistoryEntry = { + timestamp: '2026-07-28T12:00:00Z', + role: 'deposit', + txid: 'tx1', + cj_amount: 5000, + net_fee: 0, + confirmations: 10, + success: true, + source_mixdepth: 0, +} + +const mockSend: HistoryEntry = { + timestamp: '2026-07-28T13:00:00Z', + role: 'send', + txid: 'tx2', + cj_amount: 10000, + net_fee: -100, + confirmations: 0, + success: true, + source_mixdepth: 0, +} + +const mockFailed: HistoryEntry = { + timestamp: '2026-07-28T14:00:00Z', + role: 'taker', + txid: 'tx3', + cj_amount: 0, + net_fee: 0, + confirmations: 0, + success: false, + failure_reason: 'aborted', + source_mixdepth: 1, +} + +describe('TxHistoryTable', () => { + beforeEach(() => { + mocks.toastSuccess.mockReset() + mocks.toastError.mockReset() + }) + + it('renders history rows and sorts by date', () => { + render() + + expect(screen.getByText('tx_history.role_deposit')).toBeInTheDocument() + expect(screen.getByText('tx_history.role_send')).toBeInTheDocument() + + const dateHeader = screen.getByText('tx_history.column_date') + fireEvent.click(dateHeader) + expect(dateHeader).toBeInTheDocument() + }) + + it('renders empty history alert', () => { + render() + expect(screen.getByText('tx_history.empty_history')).toBeInTheDocument() + }) + + it('expands row details to show raw JSON', () => { + render() + + const detailsButton = screen.getByRole('button', { name: 'jar_details.utxo_list.row_button_details' }) + fireEvent.click(detailsButton) + + // Check if the JSON is rendered + expect(screen.getByText(/"txid": "tx1"/u)).toBeInTheDocument() + }) + + it('sorts columns correctly', () => { + render() + + fireEvent.click(screen.getByText('tx_history.column_amount')) + fireEvent.click(screen.getByText('tx_history.column_net_fee')) + fireEvent.click(screen.getByText('tx_history.column_confirmations')) + fireEvent.click(screen.getByText('tx_history.column_role')) + + expect(screen.getByText('tx_history.role_taker')).toBeInTheDocument() + }) +}) diff --git a/src/components/wallet/TxHistoryTable.tsx b/src/components/wallet/TxHistoryTable.tsx new file mode 100644 index 000000000..fefe61f1d --- /dev/null +++ b/src/components/wallet/TxHistoryTable.tsx @@ -0,0 +1,300 @@ +import { useMemo, useState } from 'react' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getExpandedRowModel, + getPaginationRowModel, + getSortedRowModel, + type CellContext, + type PaginationState, + type Row, + type SortingState, + useReactTable, +} from '@tanstack/react-table' +import type { TFunction } from 'i18next' +import { CheckIcon, ChevronDownIcon, CopyIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { TablePagination } from '@/components/ui/jam/TablePagination' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import type { HistoryEntry } from '@/hooks/useQueryWalletHistory' +import { cn, shortenStringMiddle } from '@/lib/utils' +import { Alert, AlertDescription } from '../ui/alert' +import { Button } from '../ui/button' +import { buttonVariants } from '../ui/button-variants' +import { Card, CardContent } from '../ui/card' +import { Balance } from '../ui/jam/Balance' +import { CopyButton } from '../ui/jam/CopyButton' +import { SortIcon } from '../ui/jam/SortIcon' +import { StatusBadge } from '../ui/jam/StatusBadge' + +const ITEMS_PER_PAGE = 25 + +type KnownHistoryRole = 'maker' | 'taker' | 'send' | 'deposit' +type StatusBadgeVariant = NonNullable[0]['variant']> + +const ROLE_VARIANT: Record = { + maker: 'cj-out', + taker: 'default', + send: 'used-empty', + deposit: 'deposit', +} + +const columnHelper = createColumnHelper() + +const historyRole = (entry: HistoryEntry): KnownHistoryRole | undefined => { + return ['maker', 'taker', 'send', 'deposit'].includes(entry.role ?? '') ? (entry.role as KnownHistoryRole) : undefined +} + +const roleLabel = (entry: HistoryEntry, t: TFunction) => { + const role = historyRole(entry) + return role ? t(`tx_history.role_${role}`) : t('tx_history.role_unknown') +} + +const dateTimeValue = (value: string) => { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? 0 : date.getTime() +} + +const formatDateTime = (value: string) => { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date.toLocaleString() +} + +const TxHistoryDetails = ({ entry }: { entry: HistoryEntry }) => { + return ( + + +
{JSON.stringify(entry, null, 2)}
+
+
+ ) +} + +const TxHistoryTableRow = ({ row }: { row: Row }) => { + return ( + <> + + {row.getVisibleCells().map((cell) => { + const alignCenter = cell.column.columnDef.meta?.align === 'center' + const alignRight = cell.column.columnDef.meta?.align === 'right' + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + {row.getIsExpanded() && ( + + + + + + )} + + ) +} + +const txHistoryTableColumns = (t: TFunction) => { + return [ + columnHelper.accessor('timestamp', { + header: () => t('tx_history.column_date'), + sortingFn: (a, b) => dateTimeValue(a.original.timestamp) - dateTimeValue(b.original.timestamp), + cell: (info) => {formatDateTime(info.getValue())}, + meta: { + alphabetic: true, + }, + }), + columnHelper.accessor('role', { + header: () => t('tx_history.column_role'), + sortingFn: (a, b) => roleLabel(a.original, t).localeCompare(roleLabel(b.original, t)), + cell: (info) => { + const role = historyRole(info.row.original) + return ( + {roleLabel(info.row.original, t)} + ) + }, + }), + columnHelper.accessor('cj_amount', { + header: () => t('tx_history.column_amount'), + sortingFn: (a, b) => (a.original.cj_amount ?? 0) - (b.original.cj_amount ?? 0), + cell: (info) => , + meta: { + numeric: true, + align: 'right', + }, + }), + columnHelper.accessor('net_fee', { + header: () => t('tx_history.column_net_fee'), + sortingFn: (a, b) => (a.original.net_fee ?? 0) - (b.original.net_fee ?? 0), + cell: (info) => , + meta: { + numeric: true, + align: 'right', + }, + }), + columnHelper.accessor('confirmations', { + header: () => t('tx_history.column_confirmations'), + sortingFn: (a, b) => (a.original.confirmations ?? 0) - (b.original.confirmations ?? 0), + cell: (info) => <>{info.getValue() ?? 0}, + meta: { + numeric: true, + align: 'center', + }, + }), + columnHelper.accessor('txid', { + header: () => t('tx_history.column_txid'), + cell: (info) => { + const txid = info.getValue() ?? '' + return txid ? ( +
+ {shortenStringMiddle(txid, 16)} + } + successText={} + className={cn(buttonVariants({ variant: 'outline', size: 'icon-xs' }), 'shrink-0')} + onSuccess={() => toast.success(t('tx_history.copy_txid_success'))} + onError={() => toast.error(t('tx_history.copy_txid_error'))} + /> +
+ ) : ( + '' + ) + }, + enableSorting: false, + }), + { + id: 'expand-col', + cell: ({ row }: CellContext) => { + return row.getCanExpand() ? ( + + ) : ( + '' + ) + }, + enableSorting: false, + }, + ] +} + +interface TxHistoryTableProps { + history: HistoryEntry[] + compact?: boolean +} + +export const TxHistoryTable = ({ history, compact = false }: TxHistoryTableProps) => { + const { t } = useTranslation() + const [sorting, setSorting] = useState([{ id: 'timestamp', desc: true }]) + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: compact ? 5 : ITEMS_PER_PAGE, + }) + + const columns = useMemo(() => txHistoryTableColumns(t), [t]) + + const table = useReactTable({ + data: history, + columns, + state: { + sorting, + pagination, + }, + autoResetPageIndex: true, + getRowId: (row, index) => `${row.txid || 'notxid'}-${row.source_mixdepth ?? 0}-${row.timestamp}-${index}`, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getRowCanExpand: () => true, + getExpandedRowModel: getExpandedRowModel(), + paginateExpandedRows: false, + }) + + if (history.length === 0) { + return ( + + {t('tx_history.empty_history')} + + ) + } + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const canSort = header.column.getCanSort() + const alignCenter = header.column.columnDef.meta?.align === 'center' + const alignRight = header.column.columnDef.meta?.align === 'right' + return ( + header.column.toggleSorting() : undefined} + > +
0 && !header.column.getIsSorted(), + })} + > + {flexRender(header.column.columnDef.header, header.getContext())} + {canSort ? : undefined} +
+
+ ) + })} +
+ ))} +
+ + {table.getRowModel().rows.map((row) => ( + + ))} + +
+
+ + {!compact && ( + table.setPageIndex(page - 1)} + onItemsPerPageChange={(newItemsPerPage) => { + table.setPageSize(newItemsPerPage === -1 ? history.length || 1 : newItemsPerPage) + table.setPageIndex(0) + }} + /> + )} +
+ ) +} diff --git a/src/hooks/useQueryWalletHistory.test.ts b/src/hooks/useQueryWalletHistory.test.ts new file mode 100644 index 000000000..440e5fa8a --- /dev/null +++ b/src/hooks/useQueryWalletHistory.test.ts @@ -0,0 +1,72 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { WalletFileName } from '@/lib/utils' +import { useQueryWalletHistory } from './useQueryWalletHistory' + +const queryMock = vi.fn<(options: unknown) => unknown>() + +vi.mock('@tanstack/react-query', () => ({ + useQuery: (options: unknown) => queryMock(options), +})) + +vi.mock('zustand', () => ({ + useStore: () => ({ session: 'mock-session' }), +})) + +vi.mock('@/hooks/useApiClient', () => ({ + useApiClient: () => ({}), +})) + +vi.mock('@/constants/debugFeatures', () => ({ + isDevMode: () => false, +})) + +vi.mock('@/lib/queryClient', () => ({ + withQueryDelay: (function_: unknown) => function_, +})) + +vi.mock('@/store/jmSessionStore', () => ({ + jmSessionStore: {}, +})) + +vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({ + wallethistoryOptions: () => ({ queryKey: ['history'], queryFn: vi.fn() }), +})) + +describe('useQueryWalletHistory', () => { + it('returns history and queryResult when data exists', () => { + const mockHistory = [{ txid: 'tx1', timestamp: '2026-07-28' }] + queryMock.mockReturnValue({ data: { history: mockHistory } }) + + const { result } = renderHook(() => useQueryWalletHistory({ walletFileName: 'wallet.jmdat' })) + + expect(result.current.history).toEqual(mockHistory) + expect(result.current.queryResult.data?.history).toEqual(mockHistory) + }) + + it('returns empty array when data does not exist', () => { + queryMock.mockReturnValue({ data: null }) + + const { result } = renderHook(() => useQueryWalletHistory({ walletFileName: 'wallet.jmdat' })) + + expect(result.current.history).toEqual([]) + }) + + it('is not enabled if walletFileName is empty', () => { + queryMock.mockImplementation((options) => options) + + const { result } = renderHook(() => + useQueryWalletHistory({ walletFileName: undefined as unknown as WalletFileName }), + ) + + expect((result.current.queryResult as { enabled?: boolean }).enabled).toBe(false) + }) + + it('is not enabled if explicitly disabled', () => { + queryMock.mockImplementation((options) => options) + + const { result } = renderHook(() => useQueryWalletHistory({ walletFileName: 'wallet.jmdat', enabled: false })) + + expect((result.current.queryResult as { enabled?: boolean }).enabled).toBe(false) + }) +}) diff --git a/src/hooks/useQueryWalletHistory.ts b/src/hooks/useQueryWalletHistory.ts new file mode 100644 index 000000000..119741ed6 --- /dev/null +++ b/src/hooks/useQueryWalletHistory.ts @@ -0,0 +1,53 @@ +import { wallethistoryOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query' +import type { HistoryEntry, WalletHistoryResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' +import { useQuery, type UseQueryResult } from '@tanstack/react-query' +import { useStore } from 'zustand' +import { isDevMode } from '@/constants/debugFeatures' +import { useApiClient } from '@/hooks/useApiClient' +import { withQueryDelay } from '@/lib/queryClient' +import type { WalletFileName } from '@/lib/utils' +import { jmSessionStore } from '@/store/jmSessionStore' + +export type { HistoryEntry } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' + +export type UseQueryWalletHistoryResult = { + history: HistoryEntry[] + queryResult: UseQueryResult +} + +interface UseQueryWalletHistoryProps { + walletFileName: WalletFileName + limit?: number + enabled?: boolean +} + +const EMPTY_HISTORY: HistoryEntry[] = [] + +export function useQueryWalletHistory({ + walletFileName, + limit, + enabled = true, +}: UseQueryWalletHistoryProps): UseQueryWalletHistoryResult { + const client = useApiClient() + const jmSession = useStore(jmSessionStore, (state) => state.state?.session) + + const queryOptions = wallethistoryOptions({ + client, + path: { walletname: walletFileName || '' }, + query: { limit }, + }) + + const queryResult = useQuery({ + ...queryOptions, + queryFn: withQueryDelay(queryOptions.queryFn, { + // simulate slow mainnet responses in dev mode + throttle: isDevMode() ? 210 : 0, + }), + enabled: enabled && !!walletFileName && !!jmSession, + }) + + return { + history: queryResult.data?.history ?? EMPTY_HISTORY, + queryResult, + } +} diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index d9bf7c885..9b7e960ab 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -887,5 +887,26 @@ "subtitle_one": "The selected UTXO is eligible for inclusion in the transaction.", "subtitle_other": "The selected UTXOs are eligible for inclusion in the transaction.", "text_subtitle_addon": "Unselected UTXOs will be frozen and can be unfrozen later anytime." + }, + "tx_history": { + "section_title": "Recent Activity", + "overlay_title": "Transaction History", + "button_view_all": "View All", + "button_load_more": "Load More", + "empty_history": "No transactions found.", + "error_loading": "Failed to load transaction history.", + "copy_txid_success": "Transaction ID copied.", + "copy_txid_error": "Failed to copy transaction ID.", + "column_date": "Date", + "column_role": "Type", + "column_amount": "Amount", + "column_net_fee": "Net Fee", + "column_confirmations": "Confs.", + "column_txid": "Txid", + "role_maker": "Maker", + "role_taker": "CoinJoin", + "role_send": "Send", + "role_deposit": "Deposit", + "role_unknown": "Unknown" } }