Skip to content
Merged
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
140 changes: 140 additions & 0 deletions __tests__/lib/csvExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { escapeCsvValue, toCsv, downloadCsv, type CsvColumn } from '@/lib/csvExport';

interface Row {
name: string;
amount: number | null;
note?: string;
}

const COLUMNS: CsvColumn<Row>[] = [
{ header: 'Name', value: (row) => row.name },
{ header: 'Amount', value: (row) => row.amount },
{ header: 'Note', value: (row) => row.note },
];

describe('escapeCsvValue', () => {
it('returns simple values unchanged', () => {
expect(escapeCsvValue('TRK001')).toBe('TRK001');
expect(escapeCsvValue(42)).toBe('42');
expect(escapeCsvValue(0)).toBe('0');
});

it('renders nullish values as empty cells', () => {
expect(escapeCsvValue(null)).toBe('');
expect(escapeCsvValue(undefined)).toBe('');
});

it('quotes values containing a comma', () => {
expect(escapeCsvValue('Lagos, Nigeria')).toBe('"Lagos, Nigeria"');
});

it('quotes and doubles embedded double quotes', () => {
expect(escapeCsvValue('He said "hi"')).toBe('"He said ""hi"""');
});

it('quotes values containing newlines', () => {
expect(escapeCsvValue('line one\nline two')).toBe('"line one\nline two"');
expect(escapeCsvValue('line one\r\nline two')).toBe('"line one\r\nline two"');
});
});

describe('toCsv', () => {
it('writes a header row followed by one line per record', () => {
const csv = toCsv(
[
{ name: 'Ada', amount: 10 },
{ name: 'Obi', amount: 20, note: 'urgent' },
],
COLUMNS,
);

expect(csv.split('\r\n')).toEqual(['Name,Amount,Note', 'Ada,10,', 'Obi,20,urgent']);
});

it('still writes the header row for an empty dataset', () => {
expect(toCsv([], COLUMNS)).toBe('Name,Amount,Note');
});

it('escapes cells that would otherwise break the column layout', () => {
const csv = toCsv([{ name: 'Doe, Jane', amount: null, note: 'says "ok"' }], COLUMNS);

expect(csv).toBe('Name,Amount,Note\r\n"Doe, Jane",,"says ""ok"""');
});

it('escapes header text too', () => {
const csv = toCsv<Row>([], [{ header: 'Name, full', value: (row) => row.name }]);

expect(csv).toBe('"Name, full"');
});
});

describe('downloadCsv', () => {
const createObjectURL = jest.fn(() => 'blob:mock-url');
const revokeObjectURL = jest.fn();
let clickSpy: jest.SpyInstance;

beforeEach(() => {
jest.clearAllMocks();
(URL as unknown as { createObjectURL: unknown }).createObjectURL = createObjectURL;
(URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revokeObjectURL;
clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
});

afterEach(() => {
clickSpy.mockRestore();
});

it('creates a CSV blob, clicks a download link and revokes the URL', () => {
downloadCsv('Name\r\nAda', 'report.csv');

expect(createObjectURL).toHaveBeenCalledTimes(1);
const blob = createObjectURL.mock.calls[0][0] as unknown as Blob;
expect(blob.type).toBe('text/csv;charset=utf-8;');

expect(clickSpy).toHaveBeenCalledTimes(1);
const link = clickSpy.mock.instances[0] as HTMLAnchorElement;
expect(link.download).toBe('report.csv');
expect(link.getAttribute('href')).toBe('blob:mock-url');

expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});

it('prefixes the payload with a UTF-8 BOM', () => {
downloadCsv('Name\r\nAda', 'report.csv');

const blob = createObjectURL.mock.calls[0][0] as unknown as Blob;
// 'Name\r\nAda' is 9 ASCII bytes; the 3-byte BOM brings the blob to 12.
expect(blob.size).toBe(12);
});

it('writes the content through unchanged once the BOM is decoded away', async () => {
downloadCsv('Name\r\nAda', 'report.csv');

const blob = createObjectURL.mock.calls[0][0] as unknown as Blob;
const text = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsText(blob);
});

// FileReader strips the BOM when decoding as UTF-8, as spreadsheet apps do.
expect(text).toBe('Name\r\nAda');
});

it('leaves no anchor behind in the document', () => {
downloadCsv('Name', 'report.csv');

expect(document.querySelectorAll('a[download]')).toHaveLength(0);
});

it('cleans up even when the click throws', () => {
clickSpy.mockImplementation(() => {
throw new Error('Download blocked');
});

expect(() => downloadCsv('Name', 'report.csv')).toThrow('Download blocked');
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
expect(document.querySelectorAll('a[download]')).toHaveLength(0);
});
});
185 changes: 185 additions & 0 deletions components/transactions/TransactionHistoryTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
'use client';

import { Download, Receipt } from 'lucide-react';
import { useTransactionExport } from '@/hooks/useTransactionExport';
import type { TransactionRecord } from '@/services/transactionHistoryService';
import type { TransactionStatus } from '@/types/transaction';

interface TransactionHistoryTableProps {
transactions: TransactionRecord[];
isLoading?: boolean;
error?: string | null;
}

const STATUS_COLORS: Record<TransactionStatus, string> = {
PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
SUCCESS: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
CONFIRMED: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
FAILED: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};

function formatDate(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '--';
return parsed.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}

function truncateHash(hash: string): string {
return hash.length > 16 ? `${hash.slice(0, 8)}...${hash.slice(-6)}` : hash;
}

/**
* TransactionHistoryTable — the settlement history table with a CSV export.
*
* "Export to CSV" serialises exactly the rows this table is rendering, so the
* downloaded file always matches what the user can see.
*/
export function TransactionHistoryTable({
transactions,
isLoading = false,
error = null,
}: TransactionHistoryTableProps) {
const {
isExporting,
error: exportError,
didExport,
exportToCsv,
clearError,
} = useTransactionExport();

if (isLoading) {
return (
<div
role="status"
aria-live="polite"
className="p-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
Loading transactions...
</div>
);
}

if (error) {
return (
<div
role="alert"
className="rounded-lg border border-red-200 bg-red-50 p-6 text-center text-sm font-medium text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300"
>
{error}
</div>
);
}

const hasRows = transactions.length > 0;

return (
<section className="w-full" aria-label="Transaction history">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Transaction History
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400" data-testid="transaction-count">
{transactions.length} transaction{transactions.length === 1 ? '' : 's'}
</p>
</div>

<button
type="button"
onClick={() => exportToCsv(transactions)}
disabled={!hasRows || isExporting}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-500 dark:disabled:bg-gray-700 dark:disabled:text-gray-400"
>
<Download className="h-4 w-4" aria-hidden="true" />
{isExporting ? 'Preparing CSV...' : 'Export to CSV'}
</button>
</div>

{exportError && (
<div
role="alert"
className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300"
>
<span>{exportError}</span>
<button
type="button"
onClick={clearError}
className="text-xs font-semibold underline"
>
Dismiss
</button>
</div>
)}

{didExport && !exportError && (
<p
role="status"
className="mb-4 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300"
>
Your transactions have been exported.
</p>
)}

{hasRows ? (
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table className="w-full text-left text-sm" aria-label="Transactions">
<thead className="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<tr>
<th scope="col" className="px-4 py-3">
Date
</th>
<th scope="col" className="px-4 py-3">
Hash
</th>
<th scope="col" className="px-4 py-3">
Type
</th>
<th scope="col" className="px-4 py-3">
Amount
</th>
<th scope="col" className="px-4 py-3">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{transactions.map((transaction) => (
<tr key={transaction.id} className="bg-white dark:bg-gray-900">
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
{formatDate(transaction.date)}
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-900 dark:text-white">
{truncateHash(transaction.hash)}
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-300">{transaction.type}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-300">
{transaction.amount} {transaction.currency}
</td>
<td className="px-4 py-3">
<span
className={`inline-block rounded-full px-3 py-1 text-xs font-medium ${
STATUS_COLORS[transaction.status] ?? ''
}`}
>
{transaction.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex flex-col items-center gap-2 py-12 text-center">
<Receipt className="h-10 w-10 text-gray-300 dark:text-gray-600" aria-hidden="true" />
<p className="text-base font-medium text-gray-500 dark:text-gray-400">
No transactions yet
</p>
<p className="text-sm text-gray-400 dark:text-gray-500">
Settlements appear here once an escrow is funded or released.
</p>
</div>
)}
</section>
);
}
Loading
Loading