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
4 changes: 4 additions & 0 deletions src/components/MainWalletPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
),
}))

vi.mock('./wallet/TxHistoryContent', () => ({
TxHistoryContent: () => <div data-testid="TxHistoryContent" />,
}))

vi.mock('./ui/jam/Balance', () => ({
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
<span onClick={onClick}>{valueString}</span>
Expand Down
11 changes: 11 additions & 0 deletions src/components/MainWalletPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,6 +32,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
const navigate = useNavigate()
const [selectedJar, setSelectedJar] = useState<JarObject>()
const [isWalletJarsDetailsOpen, setIsWalletJarsDetailsOpen] = useState(false)
const [isTxHistoryOpen, setIsTxHistoryOpen] = useState(false)

const { toggleDisplayMode } = useJamDisplayContext()
const { isLoading, isFetching, error, refetch: refetchWalletData } = useJamWalletInfoContext()
Expand All @@ -55,6 +58,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
walletFileName={walletFileName}
selectedJarIndex={selectedJar?.jarIndex}
/>
<TxHistoryOverlay open={isTxHistoryOpen} onOpenChange={setIsTxHistoryOpen} walletFileName={walletFileName} />
<div className="flex flex-col items-center justify-center gap-8 px-4 py-12">
<div className="flex w-full max-w-xl flex-col items-center justify-center gap-2">
<p className="text-muted-foreground hover:text-foreground text-xl select-all" title={walletName}>
Expand Down Expand Up @@ -152,6 +156,13 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
{t('global.refresh')}
</Button>
</div>
<TxHistoryContent
walletFileName={walletFileName}
compact={true}
initialLimit={5}
onViewAll={() => setIsTxHistoryOpen(true)}
className="w-full max-w-6xl"
/>
</div>
</>
)
Expand Down
113 changes: 113 additions & 0 deletions src/components/wallet/TxHistoryContent.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid="mock-table" data-compact={compact}>
Mock Table (Rows: {history.length})
</div>
),
}))

vi.mock('@/components/ui/alert', () => ({
Alert: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
<div data-testid="mock-alert" data-variant={variant}>
{children}
</div>
),
AlertDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))

vi.mock('@/components/ui/spinner', () => ({
Spinner: () => <div data-testid="mock-loading" />,
}))

const mockUseQuery = useQueryWalletHistory as unknown as ReturnType<typeof vi.fn>

const mockQueryResult = (overrides: Partial<UseQueryResult<WalletHistoryResponse, unknown>>) =>
({
isLoading: false,
error: null,
isFetching: false,
refetch: vi.fn(),
...overrides,
}) as UseQueryResult<WalletHistoryResponse, unknown>

describe('TxHistoryContent', () => {
it('renders loading state', () => {
mockUseQuery.mockReturnValue({
history: [],
queryResult: mockQueryResult({ isLoading: true }),
})

render(<TxHistoryContent walletFileName="wallet.jmdat" compact={false} />)
expect(screen.getByTestId('mock-loading')).toBeInTheDocument()
})

it('renders error state', () => {
mockUseQuery.mockReturnValue({
history: [],
queryResult: mockQueryResult({ error: new Error('failed') }),
})

render(<TxHistoryContent walletFileName="wallet.jmdat" compact={false} />)
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(<TxHistoryContent walletFileName="wallet.jmdat" compact={true} />)
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(<TxHistoryContent walletFileName="wallet.jmdat" compact={false} />)

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(<TxHistoryContent walletFileName="wallet.jmdat" compact={false} />)
expect(screen.queryByRole('button', { name: 'tx_history.button_load_more' })).not.toBeInTheDocument()
})
})
72 changes: 72 additions & 0 deletions src/components/wallet/TxHistoryContent.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className={cn('flex flex-col gap-3', className)}>
<div className="flex items-center justify-between gap-2">
<h2 className="text-muted-foreground text-sm font-light tracking-wide">{t('tx_history.section_title')}</h2>
{compact && onViewAll ? (
<Button size="sm" variant="outline" onClick={onViewAll}>
<ListIcon />
{t('tx_history.button_view_all')}
</Button>
) : null}
</div>

{queryResult.isLoading ? (
<div className="flex min-h-32 items-center justify-center gap-2">
<Spinner className="motion-reduce:hidden" />
{t('global.loading')}
</div>
) : queryResult.error ? (
<Alert variant="destructive">
<AlertTriangleIcon />
<AlertDescription>{t('tx_history.error_loading')}</AlertDescription>
</Alert>
) : (
<TxHistoryTable history={compact ? history.slice(0, 5) : history} compact={compact} />
)}

{!compact && history.length >= limit ? (
<div className="flex justify-center">
<Button
variant="outline"
onClick={() => setLimit((current) => current + 10)}
disabled={queryResult.isFetching}
>
{queryResult.isFetching ? <Spinner /> : undefined}
{t('tx_history.button_load_more')}
</Button>
</div>
) : null}
</section>
)
}
66 changes: 66 additions & 0 deletions src/components/wallet/TxHistoryOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid="tx-history-content" data-wallet={walletFileName}>
Content
</div>
),
}))

vi.mock('../ui/jam/PageTitle', () => ({
default: ({ title }: { title: string }) => <h1 data-testid="page-title">{title}</h1>,
}))

// Mock Dialog to avoid dealing with portals and radix UI internals
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>Close</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
}))

describe('TxHistoryOverlay', () => {
it('renders dialog content when open', () => {
render(<TxHistoryOverlay open={true} onOpenChange={vi.fn()} walletFileName="test-wallet.jmdat" />)

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(<TxHistoryOverlay open={true} onOpenChange={onOpenChange} walletFileName="test-wallet.jmdat" />)

const closeButton = screen.getByText('Close')
fireEvent.click(closeButton)

expect(onOpenChange).toHaveBeenCalledWith(false)
})

it('does not render when closed', () => {
render(<TxHistoryOverlay open={false} onOpenChange={vi.fn()} walletFileName="test-wallet.jmdat" />)
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
})
})
36 changes: 36 additions & 0 deletions src/components/wallet/TxHistoryOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<ComponentProps<typeof Dialog>, 'children'>,
'open' | 'onOpenChange'
> &
Pick<ComponentProps<typeof TxHistoryContent>, 'walletFileName'>

export function TxHistoryOverlay({ open, onOpenChange, walletFileName, ...dialogProps }: TxHistoryOverlayProps) {
const { t } = useTranslation()

return (
<Dialog open={open} onOpenChange={() => onOpenChange(false)} {...dialogProps}>
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! max-w-screen! flex-col overflow-hidden rounded-none border-none">
<DialogHeader className="px-2">
<DialogTitle className="sr-only flex items-center gap-2">

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<DialogTitle className="sr-only flex items-center gap-2">
<DialogTitle className="flex items-center gap-2">

<PageTitle title={t('tx_history.overlay_title')} />
</DialogTitle>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
</DialogTitle>
</DialogTitle>
<DialogDescription>{t('tx_history.overlay_subtitle')}</DialogDescription>

?

</DialogHeader>

<div className="flex min-h-0 flex-1 overflow-hidden">
<TxHistoryContent
enabled={open}
walletFileName={walletFileName}
className="flex h-full min-h-0 flex-1 flex-col overflow-hidden p-2 pt-0"
/>
</div>
</DialogContent>
</Dialog>
)
}
Loading