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
106 changes: 106 additions & 0 deletions src/components/common/ExportTradeHistoryCsvButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { Download, Loader2 } from 'lucide-react';
import { useAccount } from 'wagmi';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { downloadCsv } from '@/utils/transactionHistory.utils';
import {
buildTradeHistoryCsvFilename,
generateTradeHistoryCsv,
} from '@/utils/tradeLedger.utils';
import { fetchAllTrades } from '@/hooks/useAllTradeHistory';
import type { Trade } from '@/services/tradeHistory.service';

export interface ExportTradeHistoryCsvButtonProps {
/** Wallet address whose trades to export. */
walletAddress: string;
/** Optional authenticated override (e.g. for testing). */
isAuthenticated?: boolean;
/** Whether the viewer is viewing their own profile dashboard. */
isOwnProfile?: boolean;
/** Optional custom trade fetcher (e.g. for testing). */
fetchTrades?: (wallet: string) => Promise<Trade[]>;
/** Custom class name. */
className?: string;
/** Custom test ID. */
'data-testid'?: string;
}

/**
* Trade history CSV export button on the portfolio page (#857).
*
* Placed in the trade history tab header. When clicked by an authenticated
* user, fetches all trades from GET /users/:wallet/trades without pagination
* limit, displays a loading spinner on the button, generates a formatted CSV
* (columns: Date, Key Name, Type, Quantity, Price per Key, Total, Fee), and
* triggers a file download with filename trades-[wallet-truncated]-[date].csv.
* Only accessible when the user is authenticated.
*/
export const ExportTradeHistoryCsvButton: React.FC<
ExportTradeHistoryCsvButtonProps
> = ({
walletAddress,
isAuthenticated: customIsAuthenticated,
isOwnProfile = false,
fetchTrades = fetchAllTrades,
className,
'data-testid': testId = 'export-trade-history-button',
}) => {
const { address: connectedAddress, isConnected } = useAccount();
const [isLoading, setIsLoading] = useState(false);

const isAuth =
customIsAuthenticated !== undefined
? customIsAuthenticated
: Boolean(connectedAddress && (isConnected ?? true)) ||
Boolean(isOwnProfile);

// Button accessible only when the user is authenticated (#857)
if (!isAuth) {
return null;
}

const handleExport = async () => {
if (isLoading || !walletAddress) return;
setIsLoading(true);

try {
const trades = await fetchTrades(walletAddress);
const csv = generateTradeHistoryCsv(trades);
const filename = buildTradeHistoryCsvFilename(walletAddress);
downloadCsv(filename, csv);
} catch (error) {
console.error('Failed to export trade history CSV:', error);
} finally {
setIsLoading(false);
}
};

return (
<Button
type="button"
variant="outline"
onClick={handleExport}
disabled={isLoading}
data-testid={testId}
aria-busy={isLoading}
className={cn(
'gap-2 rounded-xl border-white/10 bg-white/[0.04] text-sm font-semibold text-white hover:bg-white/[0.08] hover:text-white',
className
)}
>
{isLoading ? (
<Loader2
className="size-4 animate-spin"
data-testid="export-trade-history-spinner"
aria-hidden="true"
/>
) : (
<Download className="size-4" aria-hidden="true" />
)}
Export CSV
</Button>
);
};

export default ExportTradeHistoryCsvButton;
158 changes: 158 additions & 0 deletions src/components/common/__tests__/ExportTradeHistoryCsvButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import ExportTradeHistoryCsvButton from '@/components/common/ExportTradeHistoryCsvButton';
import type { Trade } from '@/services/tradeHistory.service';

const mockUseAccount = vi.fn();
vi.mock('wagmi', () => ({
useAccount: () => mockUseAccount(),
}));

const mockDownloadCsv = vi.fn();
vi.mock('@/utils/transactionHistory.utils', () => ({
downloadCsv: (...args: unknown[]) => mockDownloadCsv(...args),
}));

const mockFetchAllTrades = vi.fn();
vi.mock('@/hooks/useAllTradeHistory', () => ({
fetchAllTrades: (...args: unknown[]) => mockFetchAllTrades(...args),
}));

const WALLET = 'GDEMOWALLET0000000000000000000000000000000000000000000000001';

const sampleTrades: Trade[] = [
{
id: 'trade-1',
keyName: 'Alpha Key',
tradeType: 'Buy',
quantity: 5,
pricePerKey: 2,
feeXlm: 0.1,
timestamp: Date.parse('2026-10-20T10:00:00.000Z'),
transactionHash: 'hash-1',
},
{
id: 'trade-2',
keyName: 'Beta Key',
tradeType: 'Sell',
quantity: 2,
pricePerKey: 4,
feeXlm: 0.05,
timestamp: Date.parse('2026-10-21T15:30:00.000Z'),
transactionHash: 'hash-2',
},
];

describe('ExportTradeHistoryCsvButton (#857)', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAccount.mockReturnValue({
address: WALLET,
isConnected: true,
});
mockFetchAllTrades.mockResolvedValue(sampleTrades);
});

it('does not render when the user is not authenticated', () => {
mockUseAccount.mockReturnValue({
address: undefined,
isConnected: false,
});

render(<ExportTradeHistoryCsvButton walletAddress={WALLET} />);

expect(
screen.queryByRole('button', { name: /export csv/i })
).not.toBeInTheDocument();
});

it('renders the Export CSV button when user is authenticated', () => {
render(<ExportTradeHistoryCsvButton walletAddress={WALLET} />);

const button = screen.getByRole('button', { name: /export csv/i });
expect(button).toBeInTheDocument();
expect(button).not.toBeDisabled();
});

it('shows loading spinner and disables button while fetch is in progress', async () => {
let resolveFetch: (trades: Trade[]) => void = () => {};
const pendingPromise = new Promise<Trade[]>(resolve => {
resolveFetch = resolve;
});
mockFetchAllTrades.mockReturnValue(pendingPromise);

render(<ExportTradeHistoryCsvButton walletAddress={WALLET} />);

const button = screen.getByRole('button', { name: /export csv/i });
fireEvent.click(button);

// Spinner should be visible and button disabled while fetching
expect(
screen.getByTestId('export-trade-history-spinner')
).toBeInTheDocument();
expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-busy', 'true');

// Resolve fetch
resolveFetch(sampleTrades);

await waitFor(() => {
expect(
screen.queryByTestId('export-trade-history-spinner')
).not.toBeInTheDocument();
expect(button).not.toBeDisabled();
});
});

it('fetches all trades for the wallet and downloads CSV with correct columns and filename', async () => {
render(<ExportTradeHistoryCsvButton walletAddress={WALLET} />);

const button = screen.getByRole('button', { name: /export csv/i });
fireEvent.click(button);

await waitFor(() => {
expect(mockFetchAllTrades).toHaveBeenCalledWith(WALLET);
expect(mockDownloadCsv).toHaveBeenCalledTimes(1);
});

const [filename, csv] = mockDownloadCsv.mock.calls[0] as [string, string];

// Filename includes truncated wallet and current date
expect(filename).toMatch(/^trades-GDEM…0001-\d{4}-\d{2}-\d{2}\.csv$/);

// CSV contains correct columns and data
const lines = csv.split('\r\n');
expect(lines[0]).toBe(
'Date,Key Name,Type,Quantity,Price per Key,Total,Fee'
);
expect(lines[1]).toBe(
'2026-10-20T10:00:00.000Z,Alpha Key,Buy,5,2,10.1,0.1'
);
expect(lines[2]).toBe(
'2026-10-21T15:30:00.000Z,Beta Key,Sell,2,4,7.95,0.05'
);
});

it('handles fetch error gracefully and resets loading state', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
mockFetchAllTrades.mockRejectedValue(new Error('Network error'));

render(<ExportTradeHistoryCsvButton walletAddress={WALLET} />);

const button = screen.getByRole('button', { name: /export csv/i });
fireEvent.click(button);

await waitFor(() => {
expect(button).not.toBeDisabled();
expect(
screen.queryByTestId('export-trade-history-spinner')
).not.toBeInTheDocument();
});

expect(mockDownloadCsv).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
2 changes: 1 addition & 1 deletion src/hooks/useAllTradeHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const MAX_TRADE_HISTORY_PAGES = 200;

/**
* Loads every page of a wallet's trade history (following the cursor) so the
* portfolio table can filter, paginate and export all matching rows (#1114).
* portfolio table can filter, paginate and export all matching rows (#857, #1114).
*/
export async function fetchAllTrades(wallet: string): Promise<Trade[]> {
const trades: Trade[] = [];
Expand Down
45 changes: 27 additions & 18 deletions src/pages/ProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import HeldKeysGrid from '@/components/common/HeldKeysGrid';
import StakingPositionsList from '@/components/common/StakingPositionsList';
import LiquidityPositionsSection from '@/components/common/LiquidityPositionsSection';
import TradeHistoryLedger from '@/components/common/TradeHistoryLedger';
import ExportTradeHistoryCsvButton from '@/components/common/ExportTradeHistoryCsvButton';
import AtomicSwapHistory from '@/components/common/AtomicSwapHistory';
import ProtocolRevenueClaim from '@/components/common/ProtocolRevenueClaim';
import ProtocolRevenueDistributionTable from '@/components/common/ProtocolRevenueDistributionTable';
Expand Down Expand Up @@ -535,13 +536,19 @@ export default function ProfilePage() {
data-testid="portfolio-trade-history-panel"
>
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-6 md:p-8">
<div className="mb-6">
<h2 className="font-grotesque text-2xl font-bold text-white">
Trade History
</h2>
<p className="mt-1 text-sm text-white/65">
A full audit trail of past buys and sells
</p>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="font-grotesque text-2xl font-bold text-white">
Trade History
</h2>
<p className="mt-1 text-sm text-white/65">
A full audit trail of past buys and sells
</p>
</div>
<ExportTradeHistoryCsvButton
walletAddress={profileWallet}
isOwnProfile={isOwnProfile}
/>
</div>{' '}
<SectionErrorBoundary sectionName="trade history">
<TradeHistoryLedger walletAddress={profileWallet} />
Expand Down Expand Up @@ -577,17 +584,19 @@ export default function ProfilePage() {

{/* Price alerts panel (#1057) */}
{activeTab === 'price-alerts' && (
<section
id="profile-panel-price-alerts"
role="tabpanel"
aria-labelledby="profile-tab-price-alerts"
data-testid="portfolio-price-alerts-panel"
>
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-6 md:p-8">
<h2 className="font-grotesque text-2xl font-bold text-white mb-6">Price Alerts</h2>
<ActivePriceAlertsList userId={profileWallet} />
</div>
</section>
<section
id="profile-panel-price-alerts"
role="tabpanel"
aria-labelledby="profile-tab-price-alerts"
data-testid="portfolio-price-alerts-panel"
>
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-6 md:p-8">
<h2 className="font-grotesque text-2xl font-bold text-white mb-6">
Price Alerts
</h2>
<ActivePriceAlertsList userId={profileWallet} />
</div>
</section>
)}

{/* Activity feed panel */}
Expand Down
Loading
Loading