From f41dc1aa5be388982d6a0624147fbdb4c7c79366 Mon Sep 17 00:00:00 2001 From: lolandriley-wq Date: Thu, 27 Aug 2026 15:38:04 +0000 Subject: [PATCH 1/4] fix(587): disable InvoiceExportButton and show spinner while export is in progress --- src/components/InvoiceExportButton.tsx | 31 ++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) 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 + )} ); } From c6e4d6656a6626cdb737f0cc5f2dd0970d36f472 Mon Sep 17 00:00:00 2001 From: lolandriley-wq Date: Thu, 27 Aug 2026 15:38:39 +0000 Subject: [PATCH 2/4] refactor(586): extract line-item rows into standalone LineItemRow component --- src/components/LineItemRow.tsx | 129 +++++++++++++++++++++++++++++++ src/components/RecipientForm.tsx | 104 ++++++------------------- 2 files changed, 151 insertions(+), 82 deletions(-) create mode 100644 src/components/LineItemRow.tsx 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)} + /> ))} )} + +
{ + detailsRefs.current[i] = el; + }} + className={`timeline-details ${isExpanded ? 'expanded' : 'collapsed'}`} + > + {evt.actor && ( +
+ +
+ )} + {evt.txHash && ( + + {evt.txHash.slice(0, 8)}…{evt.txHash.slice(-6)} ↗ + + )} +
); })} From a313663c07a44dae9c222d8536bbcc96abda64d7 Mon Sep 17 00:00:00 2001 From: lolandriley-wq Date: Thu, 27 Aug 2026 15:40:13 +0000 Subject: [PATCH 4/4] test(588): add tests for InvoiceListSentinel observer cleanup, InvoiceExportButton spinner, LineItemRow component, and InvoiceTimeline animation --- src/__tests__/InvoiceExportButton.test.tsx | 139 ++++++++++++++++ src/__tests__/InvoiceListSentinel.test.tsx | 116 ++++++++++++++ src/__tests__/InvoiceTimeline.test.tsx | 177 +++++++++++++++++++++ src/__tests__/LineItemRow.test.tsx | 168 +++++++++++++++++++ 4 files changed, 600 insertions(+) create mode 100644 src/__tests__/InvoiceExportButton.test.tsx create mode 100644 src/__tests__/InvoiceListSentinel.test.tsx create mode 100644 src/__tests__/InvoiceTimeline.test.tsx create mode 100644 src/__tests__/LineItemRow.test.tsx 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'); + }); +});