diff --git a/src/__tests__/InvoiceExportButton.test.tsx b/src/__tests__/InvoiceExportButton.test.tsx new file mode 100644 index 0000000..c986d70 --- /dev/null +++ b/src/__tests__/InvoiceExportButton.test.tsx @@ -0,0 +1,139 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import InvoiceExportButton from '@/components/InvoiceExportButton'; +import type { Invoice } from '@stellar-split/sdk'; + +// Mock the dynamic PDF import +jest.mock('@react-pdf/renderer', () => ({ + pdf: jest.fn(() => ({ + toBlob: jest.fn(async () => new Blob(['test'])), + })), + Document: jest.fn(() => null), + Page: jest.fn(() => null), + Text: jest.fn(() => null), + View: jest.fn(() => null), + StyleSheet: { create: jest.fn((obj) => obj) }, + Image: jest.fn(() => null), +})); + +jest.mock('@/lib/branding', () => ({ + fetchBrandSettings: jest.fn(async () => ({})), +})); + +describe('InvoiceExportButton', () => { + const mockInvoice: Invoice = { + id: 'test-id', + creator: 'GTEST', + recipients: [ + { address: 'GRECIPIENT', amount: 100n }, + ], + payments: [], + status: 'Pending', + funded: 0n, + token: 'USDC', + deadline: 0, + }; + + beforeEach(() => { + global.URL.createObjectURL = jest.fn(() => 'blob:test'); + global.URL.revokeObjectURL = jest.fn(); + HTMLAnchorElement.prototype.click = jest.fn(); + + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({}), + } as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the export button', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /export pdf/i })).toBeInTheDocument(); + }); + + it('should be enabled initially', () => { + render( + + ); + + const button = screen.getByRole('button', { name: /export pdf/i }); + expect(button).not.toBeDisabled(); + }); + + it('should be disabled while export is in progress', async () => { + render( + + ); + + const button = screen.getByRole('button', { name: /export pdf/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(button).toBeDisabled(); + }); + }); + + it('should show spinner icon while loading', async () => { + render( + + ); + + const button = screen.getByRole('button', { name: /export pdf/i }); + fireEvent.click(button); + + await waitFor(() => { + const spinner = button.querySelector('svg.animate-spin'); + expect(spinner).toBeInTheDocument(); + }); + }); + + it('should show generating text while loading', async () => { + render( + + ); + + const button = screen.getByRole('button', { name: /export pdf/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(button).toHaveTextContent('Generating…'); + }); + }); + + it('should return to normal state after export completes', async () => { + render( + + ); + + const button = screen.getByRole('button', { name: /export pdf/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(button).not.toBeDisabled(); + expect(button).toHaveTextContent('Export PDF'); + }); + }); +}); diff --git a/src/__tests__/InvoiceListSentinel.test.tsx b/src/__tests__/InvoiceListSentinel.test.tsx new file mode 100644 index 0000000..3997dd6 --- /dev/null +++ b/src/__tests__/InvoiceListSentinel.test.tsx @@ -0,0 +1,116 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import InvoiceListSentinel from '@/components/InvoiceListSentinel'; + +describe('InvoiceListSentinel', () => { + let observerMock: { observe: jest.Mock; disconnect: jest.Mock; unobserve: jest.Mock }; + let IntersectionObserverMock: jest.Mock; + + beforeEach(() => { + observerMock = { + observe: jest.fn(), + disconnect: jest.fn(), + unobserve: jest.fn(), + }; + + IntersectionObserverMock = jest.fn(() => observerMock); + (global as any).IntersectionObserver = IntersectionObserverMock; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create an IntersectionObserver on mount', () => { + const mockOnVisible = jest.fn(); + render( + + ); + + expect(IntersectionObserverMock).toHaveBeenCalled(); + expect(observerMock.observe).toHaveBeenCalled(); + }); + + it('should disconnect the IntersectionObserver on unmount', () => { + const mockOnVisible = jest.fn(); + const { unmount } = render( + + ); + + unmount(); + + expect(observerMock.disconnect).toHaveBeenCalled(); + }); + + it('should call onVisible when intersection is detected', () => { + const mockOnVisible = jest.fn(); + IntersectionObserverMock.mockImplementation((callback) => { + setTimeout(() => { + callback([{ isIntersecting: true }] as any); + }, 0); + return observerMock; + }); + + render( + + ); + + waitFor(() => { + expect(mockOnVisible).toHaveBeenCalled(); + }); + }); + + it('should display loading spinner when loading is true', () => { + const mockOnVisible = jest.fn(); + render( + + ); + + expect(screen.getByText('Loading more invoices…')).toBeInTheDocument(); + }); + + it('should display all loaded message when allLoaded is true', () => { + const mockOnVisible = jest.fn(); + render( + + ); + + expect(screen.getByText('All invoices loaded')).toBeInTheDocument(); + }); + + it('should pass custom rootMargin to IntersectionObserver', () => { + const mockOnVisible = jest.fn(); + const customMargin = '500px'; + + render( + + ); + + const callArgs = IntersectionObserverMock.mock.calls[0]; + expect(callArgs[1]).toEqual({ rootMargin: customMargin }); + }); +}); diff --git a/src/__tests__/InvoiceTimeline.test.tsx b/src/__tests__/InvoiceTimeline.test.tsx new file mode 100644 index 0000000..467dfc8 --- /dev/null +++ b/src/__tests__/InvoiceTimeline.test.tsx @@ -0,0 +1,177 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import InvoiceTimeline from '@/components/InvoiceTimeline'; +import type { InvoiceEvent } from '@/components/InvoiceTimeline'; + +// Mock the fetchEvents function +jest.mock('@/components/InvoiceTimeline', () => { + const actual = jest.requireActual('@/components/InvoiceTimeline'); + return actual; +}); + +jest.mock('@/components/WalletAddress', () => { + return function MockWalletAddress({ address }: any) { + return {address}; + }; +}); + +jest.mock('@/components/ui/RelativeTime', () => { + return function MockRelativeTime({ iso }: any) { + return {new Date(iso).toLocaleString()}; + }; +}); + +describe('InvoiceTimeline', () => { + const mockEvents: InvoiceEvent[] = [ + { + type: 'Created', + description: 'Invoice created', + timestamp: Math.floor(Date.now() / 1000), + actor: 'GCREATOR', + txHash: 'abc123def456', + }, + { + type: 'PaymentReceived', + description: '100 USDC received', + timestamp: Math.floor(Date.now() / 1000) - 3600, + actor: 'GPAYER', + txHash: 'def789ghi012', + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render timeline events', async () => { + render(); + + await waitFor(() => { + expect(screen.queryByText(/Created/i)).toBeInTheDocument(); + }); + }); + + it('should render show details button for events with details', async () => { + render(); + + await waitFor(() => { + const showDetailsButtons = screen.getAllByText(/Show details/i); + expect(showDetailsButtons.length).toBeGreaterThan(0); + }); + }); + + it('should expand details when show details button is clicked', async () => { + render(); + + await waitFor(() => { + const showDetailsButton = screen.getAllByText(/Show details/i)[0]; + fireEvent.click(showDetailsButton); + + expect(showDetailsButton).toHaveTextContent(/Hide details/i); + }); + }); + + it('should collapse details when hide details button is clicked', async () => { + render(); + + await waitFor(() => { + const showDetailsButton = screen.getAllByText(/Show details/i)[0]; + fireEvent.click(showDetailsButton); + + const hideDetailsButton = screen.getByText(/Hide details/i); + fireEvent.click(hideDetailsButton); + + expect(screen.getByText(/Show details/i)).toBeInTheDocument(); + }); + }); + + it('should display actor address when details are expanded', async () => { + render(); + + await waitFor(() => { + const showDetailsButtons = screen.getAllByText(/Show details/i); + if (showDetailsButtons.length > 0) { + fireEvent.click(showDetailsButtons[0]); + + expect(screen.getByText(/GCREATOR|GPAYER/)).toBeInTheDocument(); + } + }); + }); + + it('should display transaction hash link when details are expanded', async () => { + render(); + + await waitFor(() => { + const showDetailsButtons = screen.getAllByText(/Show details/i); + if (showDetailsButtons.length > 0) { + fireEvent.click(showDetailsButtons[0]); + + const txLinks = screen.getAllByRole('link'); + expect(txLinks.length).toBeGreaterThan(0); + } + }); + }); + + it('should have correct aria-expanded attribute', async () => { + render(); + + await waitFor(() => { + const showDetailsButtons = screen.getAllByText(/Show details/i); + if (showDetailsButtons.length > 0) { + const button = showDetailsButtons[0].closest('button'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(showDetailsButtons[0]); + + expect(button).toHaveAttribute('aria-expanded', 'true'); + } + }); + }); + + it('should apply animation classes to details section', async () => { + const { container } = render(); + + await waitFor(() => { + const detailsSections = container.querySelectorAll('.timeline-details'); + expect(detailsSections.length).toBeGreaterThan(0); + + detailsSections.forEach((section) => { + expect(section.classList.contains('collapsed')).toBe(true); + }); + }); + }); + + it('should toggle animation classes when expanding', async () => { + const { container } = render(); + + await waitFor(() => { + const showDetailsButtons = screen.getAllByText(/Show details/i); + if (showDetailsButtons.length > 0) { + const detailsSection = container.querySelector('.timeline-details'); + expect(detailsSection?.classList.contains('collapsed')).toBe(true); + + fireEvent.click(showDetailsButtons[0]); + + waitFor(() => { + expect(detailsSection?.classList.contains('expanded')).toBe(true); + }); + } + }); + }); + + it('should render animation CSS rules', async () => { + const { container } = render(); + + const styleTag = container.querySelector('style'); + expect(styleTag).toBeInTheDocument(); + expect(styleTag?.textContent).toContain('timeline-details'); + expect(styleTag?.textContent).toContain('max-height'); + expect(styleTag?.textContent).toContain('transition'); + }); + + it('should respect prefers-reduced-motion', async () => { + const { container } = render(); + + const styleTag = container.querySelector('style'); + expect(styleTag?.textContent).toContain('prefers-reduced-motion'); + }); +}); diff --git a/src/__tests__/LineItemRow.test.tsx b/src/__tests__/LineItemRow.test.tsx new file mode 100644 index 0000000..67bfb10 --- /dev/null +++ b/src/__tests__/LineItemRow.test.tsx @@ -0,0 +1,168 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import LineItemRow from '@/components/LineItemRow'; + +// Mock dependencies +jest.mock('@/components/settings/AddressBookPicker', () => { + return function MockAddressBookPicker({ value, onChange, placeholder, ariaLabel }: any) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + aria-label={ariaLabel} + /> + ); + }; +}); + +jest.mock('@/components/ui/Avatar', () => { + return function MockAvatar({ address }: any) { + return
{address}
; + }; +}); + +jest.mock('@/components/EmailField', () => { + return function MockEmailField({ email, onEmailChange }: any) { + return ( + onEmailChange(e.target.value)} + placeholder="email@example.com" + /> + ); + }; +}); + +describe('LineItemRow', () => { + const mockProps = { + index: 0, + address: 'GTEST123', + amount: '100', + label: 'Test User', + email: 'test@example.com', + equalSplit: false, + amountOverride: undefined, + amountSuggestions: [], + activeField: null as const, + activeIndex: null, + canRemove: true, + emailByAddress: { GTEST123: 'test@example.com' }, + onAddressChange: jest.fn(), + onAmountChange: jest.fn(), + onAmountFocus: jest.fn(), + onAmountSuggestionSelect: jest.fn(), + onRemove: jest.fn(), + onEmailChange: jest.fn(), + }; + + it('should render address input', () => { + render(); + const addressInput = screen.getByDisplayValue('GTEST123'); + expect(addressInput).toBeInTheDocument(); + }); + + it('should render amount input', () => { + render(); + const amountInput = screen.getByDisplayValue('100'); + expect(amountInput).toBeInTheDocument(); + }); + + it('should render email input', () => { + render(); + const emailInput = screen.getByDisplayValue('test@example.com'); + expect(emailInput).toBeInTheDocument(); + }); + + it('should render avatar with address', () => { + render(); + expect(screen.getByTestId('avatar')).toHaveTextContent('GTEST123'); + }); + + it('should render remove button when canRemove is true', () => { + render(); + const removeButton = screen.getByRole('button', { name: /remove recipient 1/i }); + expect(removeButton).toBeInTheDocument(); + }); + + it('should not render remove button when canRemove is false', () => { + render(); + const removeButton = screen.queryByRole('button', { name: /remove recipient 1/i }); + expect(removeButton).not.toBeInTheDocument(); + }); + + it('should call onRemove when remove button is clicked', () => { + const { onRemove } = mockProps; + render(); + const removeButton = screen.getByRole('button', { name: /remove recipient 1/i }); + fireEvent.click(removeButton); + expect(onRemove).toHaveBeenCalled(); + }); + + it('should call onAddressChange when address changes', () => { + const { onAddressChange } = mockProps; + render(); + const addressInput = screen.getByDisplayValue('GTEST123'); + fireEvent.change(addressInput, { target: { value: 'GNEWADDRESS' } }); + expect(onAddressChange).toHaveBeenCalledWith('GNEWADDRESS', mockProps.email, mockProps.email); + }); + + it('should call onAmountChange when amount changes', () => { + const { onAmountChange } = mockProps; + render(); + const amountInput = screen.getByDisplayValue('100'); + fireEvent.change(amountInput, { target: { value: '200' } }); + expect(onAmountChange).toHaveBeenCalledWith('200'); + }); + + it('should call onAmountFocus when amount input is focused', () => { + const { onAmountFocus } = mockProps; + render(); + const amountInput = screen.getByDisplayValue('100'); + fireEvent.focus(amountInput); + expect(onAmountFocus).toHaveBeenCalled(); + }); + + it('should call onEmailChange when email changes', () => { + const { onEmailChange } = mockProps; + render(); + const emailInput = screen.getByDisplayValue('test@example.com'); + fireEvent.change(emailInput, { target: { value: 'newemail@example.com' } }); + expect(onEmailChange).toHaveBeenCalledWith('newemail@example.com'); + }); + + it('should disable amount input when equalSplit is true', () => { + render(); + const amountInput = screen.getByDisplayValue('100'); + expect((amountInput as HTMLInputElement).readOnly).toBe(true); + }); + + it('should show amount suggestions when available', () => { + render( + + ); + expect(screen.getByText('50 USDC')).toBeInTheDocument(); + expect(screen.getByText('150 USDC')).toBeInTheDocument(); + }); + + it('should call onAmountSuggestionSelect when suggestion is clicked', () => { + const { onAmountSuggestionSelect } = mockProps; + render( + + ); + const suggestionButton = screen.getByText('50 USDC'); + fireEvent.mouseDown(suggestionButton); + expect(onAmountSuggestionSelect).toHaveBeenCalledWith('50'); + }); +}); diff --git a/src/components/InvoiceExportButton.tsx b/src/components/InvoiceExportButton.tsx index f267746..c6349e4 100644 --- a/src/components/InvoiceExportButton.tsx +++ b/src/components/InvoiceExportButton.tsx @@ -195,9 +195,36 @@ export default function InvoiceExportButton({ invoice, total, branding }: Props) type="button" onClick={handleExport} disabled={loading} - className="px-3 py-1.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm font-semibold transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500" + className="px-3 py-1.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm font-semibold transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 inline-flex items-center gap-2" > - {loading ? 'Generating…' : '↓ Export PDF'} + {loading ? ( + <> + + Generating… + + ) : ( + <>↓ Export PDF + )} ); } diff --git a/src/components/InvoiceTimeline.tsx b/src/components/InvoiceTimeline.tsx index c37246e..ca8f4ba 100644 --- a/src/components/InvoiceTimeline.tsx +++ b/src/components/InvoiceTimeline.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import WalletAddress from './WalletAddress'; import RelativeTime from '@/components/ui/RelativeTime'; @@ -63,6 +63,8 @@ export default function InvoiceTimeline({ invoiceId }: Props) { const [loading, setLoading] = useState(true); const [nextCursor, setNextCursor] = useState(); const [loadingMore, setLoadingMore] = useState(false); + const [expandedIndices, setExpandedIndices] = useState>(new Set()); + const detailsRefs = useRef<(HTMLDivElement | null)[]>([]); const load = useCallback(async () => { setLoading(true); @@ -83,6 +85,18 @@ export default function InvoiceTimeline({ invoiceId }: Props) { setLoadingMore(false); }; + const toggleExpand = (index: number) => { + setExpandedIndices((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + if (loading) { return (
@@ -109,9 +123,35 @@ export default function InvoiceTimeline({ invoiceId }: Props) { return (
+
    {events.map((evt, i) => { const meta = EVENT_META[evt.type] ?? EVENT_META.Created; + const isExpanded = expandedIndices.has(i); + const hasDetails = evt.actor || evt.txHash; return (
  1. {/* dot */} @@ -126,21 +166,41 @@ export default function InvoiceTimeline({ invoiceId }: Props) {

{evt.description}

- {evt.actor && ( -
- -
- )} - {evt.txHash && ( - toggleExpand(i)} + className="mt-1.5 inline-flex items-center gap-1 text-xs text-indigo-400 hover:text-indigo-300 transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-indigo-400" + aria-expanded={isExpanded} > - {evt.txHash.slice(0, 8)}…{evt.txHash.slice(-6)} ↗ - + {isExpanded ? '▼' : '▶'} + {isExpanded ? 'Hide details' : 'Show details'} + )} + +
{ + detailsRefs.current[i] = el; + }} + className={`timeline-details ${isExpanded ? 'expanded' : 'collapsed'}`} + > + {evt.actor && ( +
+ +
+ )} + {evt.txHash && ( + + {evt.txHash.slice(0, 8)}…{evt.txHash.slice(-6)} ↗ + + )} +
); })} diff --git a/src/components/LineItemRow.tsx b/src/components/LineItemRow.tsx new file mode 100644 index 0000000..d363b08 --- /dev/null +++ b/src/components/LineItemRow.tsx @@ -0,0 +1,129 @@ +'use client'; + +import AddressBookPicker from '@/components/settings/AddressBookPicker'; +import Avatar from '@/components/ui/Avatar'; +import EmailField from '@/components/EmailField'; + +export interface LineItemRowProps { + index: number; + address: string; + amount: string; + label?: string; + email?: string; + equalSplit?: boolean; + amountOverride?: string; + amountSuggestions?: string[]; + activeField?: 'address' | 'amount' | null; + activeIndex?: number | null; + canRemove: boolean; + emailByAddress?: Record; + onAddressChange: (address: string, label?: string, email?: string) => void; + onAmountChange: (amount: string) => void; + onAmountFocus: () => void; + onAmountSuggestionSelect: (amount: string) => void; + onRemove: () => void; + onEmailChange: (email: string) => void; +} + +export default function LineItemRow({ + index, + address, + amount, + label, + email, + equalSplit = false, + amountOverride, + amountSuggestions = [], + activeField = null, + activeIndex = null, + canRemove, + emailByAddress = {}, + onAddressChange, + onAmountChange, + onAmountFocus, + onAmountSuggestionSelect, + onRemove, + onEmailChange, +}: LineItemRowProps) { + return ( +
+
+ + +
+ onAddressChange(addr, lbl, email)} + placeholder="G... or name*domain.com address" + ariaLabel={`Recipient ${index + 1} address`} + /> +
+ +
+ onAmountChange(e.target.value) + } + onFocus={() => !equalSplit && onAmountFocus()} + readOnly={equalSplit} + required + aria-label={`Recipient ${index + 1} amount`} + className={`w-full bg-gray-800 border rounded-lg px-3 py-2 min-h-11 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${ + equalSplit + ? 'border-gray-600 text-gray-400 cursor-not-allowed' + : 'border-gray-700' + }`} + /> + {activeField === 'amount' && activeIndex === index && amountSuggestions.length > 0 && !equalSplit && ( +
    + {amountSuggestions.map((amt) => ( +
  • + +
  • + ))} +
+ )} +
+ + {canRemove && ( + + )} +
+ +
+
+
+ {}} + /> +
+
+
+ ); +} diff --git a/src/components/RecipientForm.tsx b/src/components/RecipientForm.tsx index 8d4d8d1..276c05b 100644 --- a/src/components/RecipientForm.tsx +++ b/src/components/RecipientForm.tsx @@ -1,15 +1,13 @@ "use client"; import { useState, useRef, useMemo, useEffect } from "react"; -import AddressBookPicker from "@/components/settings/AddressBookPicker"; import { searchEntries, addEntry, getEmailForAddress, type AddressEntry } from "@/lib/addressBook"; -import Avatar from "@/components/ui/Avatar"; import { searchAddressHistory, searchAmountHistory } from "@/lib/invoiceHistory"; import { searchRecipients, touchRecipient, type RecipientEntry } from "@/lib/recipients"; import { truncateAddress } from "@stellar-split/sdk"; import CsvRecipientImport from "@/components/CsvRecipientImport"; import { useEmailValidation } from "@/hooks/useEmailValidation"; -import EmailField from "@/components/EmailField"; +import LineItemRow from "@/components/LineItemRow"; export interface RecipientRow { address: string; @@ -171,85 +169,27 @@ export default function RecipientForm({ return (
{recipients.map((row, i) => ( -
-
- - -
- updateRow(i, address, label, row.email)} - placeholder="G... or name*domain.com address" - ariaLabel={`Recipient ${i + 1} address`} - /> -
- -
- handleAmountChange(i, e.target.value) - } - onFocus={() => !equalSplit && handleAmountFocus(i)} - readOnly={equalSplit} - required - aria-label={`Recipient ${i + 1} amount`} - className={`w-full bg-gray-800 border rounded-lg px-3 py-2 min-h-11 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${ - equalSplit - ? "border-gray-600 text-gray-400 cursor-not-allowed" - : "border-gray-700" - }`} - /> - {activeField === "amount" && activeIndex === i && amountSuggestions.length > 0 && !equalSplit && ( -
    - {amountSuggestions.map((amount) => ( -
  • - -
  • - ))} -
- )} -
- - {recipients.length > 1 && ( - - )} -
- -
-
-
- updateRow(i, row.address, row.label, email)} - onBlur={() => {}} - /> -
-
-
+ 1} + emailByAddress={emailByAddress} + onAddressChange={(address, label, email) => updateRow(i, address, label, email)} + onAmountChange={(amount) => handleAmountChange(i, amount)} + onAmountFocus={() => handleAmountFocus(i)} + onAmountSuggestionSelect={(amount) => selectAmountSuggestion(i, amount)} + onRemove={() => removeRow(i)} + onEmailChange={(email) => updateRow(i, row.address, row.label, email)} + /> ))}