Skip to content

Commit d136901

Browse files
committed
fixup! feat(suite-native): add trade history CSV export
1 parent f94f5c4 commit d136901

6 files changed

Lines changed: 156 additions & 16 deletions

File tree

suite-common/trading/src/utils/tradeHistoryExportUtils.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ const resolvers = {
4848
name === 'btcdirect-sell' ? 'BTC Direct' : name,
4949
};
5050

51+
const CSV_BOM = '';
52+
5153
const buyTrade: TradingTransactionBuy = {
5254
tradeType: 'buy',
5355
date: '2025-04-10T20:21:25.042Z',
@@ -245,7 +247,7 @@ describe('tradeHistoryExportUtils', () => {
245247
const header = TRADING_HISTORY_CSV_COLUMNS.map(column => labels[column]).join(',');
246248

247249
it('returns only the header for an empty trade list', () => {
248-
expect(buildTradingHistoryCsv(labels)([], resolvers)).toBe(header);
250+
expect(buildTradingHistoryCsv(labels)([], resolvers)).toBe(CSV_BOM + header);
249251
});
250252

251253
it('builds a header plus one line per trade', () => {
@@ -254,7 +256,7 @@ describe('tradeHistoryExportUtils', () => {
254256
const lines = csv.split('\n');
255257

256258
expect(lines).toHaveLength(4);
257-
expect(lines[0]).toBe(header);
259+
expect(lines[0]).toBe(CSV_BOM + header);
258260
expect(lines[1]).toBe(
259261
'buy-order,2025-04-10T20:21:25.042Z,buy,1234,USD,,,0.462586,ETH,Ethereum,mercuryo,SUCCESS,buy-receive-hash,buy-payment',
260262
);
@@ -268,7 +270,7 @@ describe('tradeHistoryExportUtils', () => {
268270
const [headerRow] = buildTradingHistoryCsv(czLabels)([], resolvers).split('\n');
269271

270272
expect(headerRow).toBe(
271-
TRADING_HISTORY_CSV_COLUMNS.map(column => `cs:${column}`).join(','),
273+
CSV_BOM + TRADING_HISTORY_CSV_COLUMNS.map(column => `cs:${column}`).join(','),
272274
);
273275
});
274276

suite-common/trading/src/utils/tradeHistoryExportUtils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getTradeOperationData } from './tradeOperationUtils';
1212
const CSV_NEWLINE = '\n';
1313
const CSV_SEPARATOR = ',';
1414
const CSV_LEADING_CHARACTERS_TO_ESCAPE_REGEX = /^[\s\uFEFF]*[=+\-@]/u;
15+
const CSV_BOM = '\uFEFF';
1516

1617
export const sanitizeTradingCsvValue = (value: string): string => {
1718
const sanitizedValue = CSV_LEADING_CHARACTERS_TO_ESCAPE_REGEX.test(value) ? `'${value}` : value;
@@ -118,7 +119,7 @@ export const buildTradingHistoryCsv =
118119
).join(CSV_SEPARATOR);
119120
});
120121

121-
return [header, ...rows].join(CSV_NEWLINE);
122+
return CSV_BOM + [header, ...rows].join(CSV_NEWLINE);
122123
};
123124

124125
export const prepareTradingHistoryCsv =

suite-native/trading-history/src/components/TradingHistoryExportButton.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,16 @@ describe('TradingHistoryExportButton', () => {
142142
);
143143
});
144144

145+
it('shows no toast when the export is cancelled by dismissing the directory picker', async () => {
146+
mockExportTradingHistoryCsv.mockResolvedValue({ success: false, reason: 'cancelled' });
147+
renderExportButton();
148+
149+
await confirmExport();
150+
151+
expect(mockExportTradingHistoryCsv).toHaveBeenCalledTimes(1);
152+
expect(mockShowToast).not.toHaveBeenCalled();
153+
});
154+
145155
it('dismisses the alert without exporting when cancelled', () => {
146156
renderExportButton();
147157

suite-native/trading-history/src/components/TradingHistoryExportButton.tsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import { Translation, useTranslate } from '@suite-native/intl';
1313
import { useToast } from '@suite-native/toasts';
1414
import { exhaustive } from '@trezor/type-utils';
1515

16-
import { exportTradingHistoryCsv } from '../exportTradingHistoryCsv';
16+
import {
17+
type ExportTradingHistoryCsvResult,
18+
exportTradingHistoryCsv,
19+
} from '../exportTradingHistoryCsv';
1720
import { useTradingHistoryCsvColumnLabels } from '../hooks/useTradingHistoryCsvColumnLabels';
1821

1922
export const TradingHistoryExportButton = () => {
@@ -30,18 +33,22 @@ export const TradingHistoryExportButton = () => {
3033
}
3134

3235
const performExport = async () => {
33-
setIsExporting(true);
36+
let result: ExportTradingHistoryCsvResult = { success: false, reason: 'exportFailed' };
3437

35-
// wait for alert animation to finish before starting the export, 500 ms ought to be enough for anybody.
36-
await new Promise(resolve => setTimeout(resolve, 500));
38+
try {
39+
setIsExporting(true);
3740

38-
const state = store.getState();
39-
const trades = selectDeviceTradingTradesOrderedByDate(state);
40-
const csvContent = prepareTradingHistoryCsv(columnLabels)(state, trades);
41+
// wait for alert animation to finish before starting the export, 500 ms ought to be enough for anybody.
42+
await new Promise(resolve => setTimeout(resolve, 500));
4143

42-
const result = await exportTradingHistoryCsv(csvContent);
44+
const state = store.getState();
45+
const trades = selectDeviceTradingTradesOrderedByDate(state);
46+
const csvContent = prepareTradingHistoryCsv(columnLabels)(state, trades);
4347

44-
setIsExporting(false);
48+
result = await exportTradingHistoryCsv(csvContent);
49+
} finally {
50+
setIsExporting(false);
51+
}
4552

4653
if (result.success) {
4754
showToast({
@@ -56,6 +63,9 @@ export const TradingHistoryExportButton = () => {
5663
}
5764

5865
switch (result.reason) {
66+
case 'cancelled':
67+
// User dismissed the directory picker; nothing to report.
68+
return;
5969
case 'exportFailed':
6070
showToast({
6171
intent: 'critical',
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Platform } from 'react-native';
2+
3+
import { Directory } from 'expo-file-system';
4+
import * as Sharing from 'expo-sharing';
5+
6+
import { exportTradingHistoryCsv } from './exportTradingHistoryCsv';
7+
8+
const mockDirectoryWrite = jest.fn();
9+
const mockCreateFile = jest.fn(() => ({ write: mockDirectoryWrite }));
10+
11+
jest.mock('expo-file-system', () => ({
12+
File: jest.fn().mockImplementation(() => ({
13+
exists: false,
14+
create: jest.fn(),
15+
write: jest.fn(),
16+
uri: 'file:///cache/trade-history.csv',
17+
})),
18+
Paths: { cache: 'file:///cache' },
19+
Directory: {
20+
pickDirectoryAsync: jest.fn(),
21+
},
22+
}));
23+
24+
jest.mock('expo-sharing', () => ({
25+
shareAsync: jest.fn(),
26+
}));
27+
28+
const mockPickDirectoryAsync = jest.mocked(Directory.pickDirectoryAsync);
29+
const mockShareAsync = jest.mocked(Sharing.shareAsync);
30+
31+
const originalPlatformOS = Platform.OS;
32+
const setPlatformOS = (os: typeof Platform.OS) => {
33+
Platform.OS = os;
34+
};
35+
36+
const CSV_CONTENT = 'a,b,c';
37+
38+
describe('exportTradingHistoryCsv', () => {
39+
beforeEach(() => {
40+
jest.clearAllMocks();
41+
});
42+
43+
afterAll(() => {
44+
setPlatformOS(originalPlatformOS);
45+
});
46+
47+
describe('on android', () => {
48+
beforeEach(() => {
49+
setPlatformOS('android');
50+
});
51+
52+
it('writes the file into the picked directory and returns success', async () => {
53+
mockPickDirectoryAsync.mockResolvedValue({ createFile: mockCreateFile } as never);
54+
55+
const result = await exportTradingHistoryCsv(CSV_CONTENT);
56+
57+
expect(result).toEqual({ success: true });
58+
expect(mockCreateFile).toHaveBeenCalledTimes(1);
59+
expect(mockDirectoryWrite).toHaveBeenCalledWith(CSV_CONTENT);
60+
});
61+
62+
it('returns the cancelled reason when the user dismisses the directory picker', async () => {
63+
mockPickDirectoryAsync.mockRejectedValue(
64+
Object.assign(new Error('The file picker was cancelled by the user'), {
65+
code: 'ERR_PICKER_CANCELLED',
66+
}),
67+
);
68+
69+
const result = await exportTradingHistoryCsv(CSV_CONTENT);
70+
71+
expect(result).toEqual({ success: false, reason: 'cancelled' });
72+
});
73+
74+
it('returns the exportFailed reason for any other picker error', async () => {
75+
mockPickDirectoryAsync.mockRejectedValue(new Error('boom'));
76+
77+
const result = await exportTradingHistoryCsv(CSV_CONTENT);
78+
79+
expect(result).toEqual({ success: false, reason: 'exportFailed' });
80+
});
81+
});
82+
83+
describe('on ios', () => {
84+
beforeEach(() => {
85+
setPlatformOS('ios');
86+
});
87+
88+
it('shares the cached file and returns success', async () => {
89+
mockShareAsync.mockResolvedValue(undefined);
90+
91+
const result = await exportTradingHistoryCsv(CSV_CONTENT);
92+
93+
expect(result).toEqual({ success: true });
94+
expect(mockShareAsync).toHaveBeenCalledWith(
95+
'file:///cache/trade-history.csv',
96+
expect.objectContaining({ UTI: 'public.comma-separated-values-text' }),
97+
);
98+
});
99+
});
100+
101+
describe('on an unsupported platform', () => {
102+
beforeEach(() => {
103+
setPlatformOS('web');
104+
});
105+
106+
it('returns the fileSavingNotSupported reason', async () => {
107+
const result = await exportTradingHistoryCsv(CSV_CONTENT);
108+
109+
expect(result).toEqual({ success: false, reason: 'fileSavingNotSupported' });
110+
});
111+
});
112+
});

suite-native/trading-history/src/exportTradingHistoryCsv.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { Platform } from 'react-native';
33
import { Directory, File, Paths } from 'expo-file-system';
44
import * as Sharing from 'expo-sharing';
55

6-
type ExportTradingHistoryCsvResult =
6+
export type ExportTradingHistoryCsvResult =
77
| { success: true }
8-
| { success: false; reason: 'fileSavingNotSupported' | 'exportFailed' };
8+
| { success: false; reason: 'fileSavingNotSupported' | 'exportFailed' | 'cancelled' };
99

1010
const CSV_MIME_TYPE = 'text/csv';
1111

12+
const PICKER_CANCELLED_ERROR_CODE = 'ERR_PICKER_CANCELLED';
13+
1214
const buildFileName = (): string => {
1315
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1416

@@ -31,7 +33,6 @@ const saveFile = async (fileName: string, content: string): Promise<void> => {
3133
newFile.write(content);
3234
} else if (Platform.OS === 'ios') {
3335
await Sharing.shareAsync(cachedFile.uri, {
34-
mimeType: CSV_MIME_TYPE,
3536
UTI: 'public.comma-separated-values-text',
3637
});
3738
} else {
@@ -51,6 +52,10 @@ export const exportTradingHistoryCsv = async (
5152
return { success: false, reason: 'fileSavingNotSupported' };
5253
}
5354

55+
if (error?.code === PICKER_CANCELLED_ERROR_CODE) {
56+
return { success: false, reason: 'cancelled' };
57+
}
58+
5459
return { success: false, reason: 'exportFailed' };
5560
}
5661

0 commit comments

Comments
 (0)