@datarecce/ui 0.1.40 → 0.1.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.mts +1 -1
- package/dist/{components-DTLQ2djq.js → components-DfXnN1Hx.js} +592 -13
- package/dist/components-DfXnN1Hx.js.map +1 -0
- package/dist/{components-B6oaPB5f.mjs → components-jh6r4tQn.mjs} +593 -14
- package/dist/components-jh6r4tQn.mjs.map +1 -0
- package/dist/components.d.mts +1 -1
- package/dist/components.js +1 -1
- package/dist/components.mjs +1 -1
- package/dist/hooks.d.mts +1 -1
- package/dist/{index-CbF0x3kW.d.mts → index-B5bpmv0i.d.mts} +70 -70
- package/dist/{index-CbF0x3kW.d.mts.map → index-B5bpmv0i.d.mts.map} +1 -1
- package/dist/index-B9lSPJTi.d.ts.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/theme.d.mts +1 -1
- package/dist/types.d.mts +1 -1
- package/package.json +1 -1
- package/recce-source/docs/plans/2024-12-31-csv-download-design.md +121 -0
- package/recce-source/docs/plans/2024-12-31-csv-download-implementation.md +930 -0
- package/recce-source/js/src/components/run/RunResultPane.tsx +138 -14
- package/recce-source/js/src/lib/csv/extractors.test.ts +456 -0
- package/recce-source/js/src/lib/csv/extractors.ts +468 -0
- package/recce-source/js/src/lib/csv/format.test.ts +211 -0
- package/recce-source/js/src/lib/csv/format.ts +44 -0
- package/recce-source/js/src/lib/csv/index.test.ts +155 -0
- package/recce-source/js/src/lib/csv/index.ts +109 -0
- package/recce-source/js/src/lib/hooks/useCSVExport.ts +136 -0
- package/recce-source/recce/mcp_server.py +54 -30
- package/recce-source/recce/models/check.py +10 -2
- package/dist/components-B6oaPB5f.mjs.map +0 -1
- package/dist/components-DTLQ2djq.js.map +0 -1
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
# CSV Download Feature Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add CSV export capabilities to the Run Result pane with options to copy as image, copy as CSV, and download as CSV.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Create CSV utility module (`js/src/lib/csv/`) with formatting, extraction, and download functions. Refactor `RunResultShareMenu` component to expose these options in a flat menu structure. Each run type has a dedicated extractor function.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, React, MUI Menu, file-saver (already installed), Clipboard API
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Task 1: Create CSV Utility Module
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Create: `js/src/lib/csv/index.ts`
|
|
17
|
+
- Create: `js/src/lib/csv/format.ts`
|
|
18
|
+
|
|
19
|
+
**Step 1: Create the CSV format utility**
|
|
20
|
+
|
|
21
|
+
Create `js/src/lib/csv/format.ts`:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
/**
|
|
25
|
+
* CSV formatting utilities with Excel-friendly output
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Escape a value for CSV format
|
|
30
|
+
* - Wrap in quotes if contains comma, quote, or newline
|
|
31
|
+
* - Escape quotes by doubling them
|
|
32
|
+
*/
|
|
33
|
+
function escapeCSVValue(value: unknown): string {
|
|
34
|
+
if (value === null || value === undefined) {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const stringValue = typeof value === "object"
|
|
39
|
+
? JSON.stringify(value)
|
|
40
|
+
: String(value);
|
|
41
|
+
|
|
42
|
+
// Check if escaping is needed
|
|
43
|
+
if (
|
|
44
|
+
stringValue.includes(",") ||
|
|
45
|
+
stringValue.includes('"') ||
|
|
46
|
+
stringValue.includes("\n") ||
|
|
47
|
+
stringValue.includes("\r")
|
|
48
|
+
) {
|
|
49
|
+
return `"${stringValue.replace(/"/g, '""')}"`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return stringValue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Convert tabular data to CSV string
|
|
57
|
+
* @param columns - Column headers
|
|
58
|
+
* @param rows - Row data (array of arrays)
|
|
59
|
+
* @returns CSV string with UTF-8 BOM for Excel compatibility
|
|
60
|
+
*/
|
|
61
|
+
export function toCSV(columns: string[], rows: unknown[][]): string {
|
|
62
|
+
const BOM = "\uFEFF";
|
|
63
|
+
|
|
64
|
+
const headerRow = columns.map(escapeCSVValue).join(",");
|
|
65
|
+
const dataRows = rows.map((row) =>
|
|
66
|
+
row.map(escapeCSVValue).join(",")
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return BOM + [headerRow, ...dataRows].join("\r\n");
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Step 2: Create the CSV index with download and clipboard utilities**
|
|
74
|
+
|
|
75
|
+
Create `js/src/lib/csv/index.ts`:
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
/**
|
|
79
|
+
* CSV export utilities
|
|
80
|
+
*/
|
|
81
|
+
import saveAs from "file-saver";
|
|
82
|
+
|
|
83
|
+
export { toCSV } from "./format";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Trigger browser download of CSV file
|
|
87
|
+
*/
|
|
88
|
+
export function downloadCSV(content: string, filename: string): void {
|
|
89
|
+
const blob = new Blob([content], { type: "text/csv;charset=utf-8" });
|
|
90
|
+
saveAs(blob, filename);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Copy CSV content to clipboard
|
|
95
|
+
*/
|
|
96
|
+
export async function copyCSVToClipboard(content: string): Promise<void> {
|
|
97
|
+
await navigator.clipboard.writeText(content);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Generate timestamp string for filenames
|
|
102
|
+
* Format: YYYYMMDD-HHmmss
|
|
103
|
+
*/
|
|
104
|
+
export function generateTimestamp(): string {
|
|
105
|
+
const now = new Date();
|
|
106
|
+
const year = now.getFullYear();
|
|
107
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
108
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
109
|
+
const hours = String(now.getHours()).padStart(2, "0");
|
|
110
|
+
const minutes = String(now.getMinutes()).padStart(2, "0");
|
|
111
|
+
const seconds = String(now.getSeconds()).padStart(2, "0");
|
|
112
|
+
return `${year}${month}${day}-${hours}${minutes}${seconds}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Generate context-aware CSV filename
|
|
117
|
+
*/
|
|
118
|
+
export function generateCSVFilename(
|
|
119
|
+
runType: string,
|
|
120
|
+
params?: Record<string, unknown>
|
|
121
|
+
): string {
|
|
122
|
+
const timestamp = generateTimestamp();
|
|
123
|
+
const type = runType.replace(/_/g, "-");
|
|
124
|
+
|
|
125
|
+
// Try to extract node name from params
|
|
126
|
+
let nodeName: string | undefined;
|
|
127
|
+
|
|
128
|
+
if (params?.node_names && Array.isArray(params.node_names) && params.node_names.length === 1) {
|
|
129
|
+
nodeName = String(params.node_names[0]);
|
|
130
|
+
} else if (params?.model && typeof params.model === "string") {
|
|
131
|
+
nodeName = params.model;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Sanitize node name for filesystem
|
|
135
|
+
if (nodeName) {
|
|
136
|
+
nodeName = nodeName.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase();
|
|
137
|
+
return `${type}-${nodeName}-${timestamp}.csv`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return `${type}-result-${timestamp}.csv`;
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**Step 3: Verify the module compiles**
|
|
145
|
+
|
|
146
|
+
Run: `cd /Users/kliu/recceAll/recce && pnpm exec tsc --noEmit js/src/lib/csv/index.ts js/src/lib/csv/format.ts 2>&1 | head -20`
|
|
147
|
+
|
|
148
|
+
Expected: No errors (or only unrelated errors from other files)
|
|
149
|
+
|
|
150
|
+
**Step 4: Commit**
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
cd /Users/kliu/recceAll/recce
|
|
154
|
+
git add js/src/lib/csv/
|
|
155
|
+
git commit -m "feat(csv): add CSV formatting and download utilities"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Task 2: Create CSV Data Extractors
|
|
161
|
+
|
|
162
|
+
**Files:**
|
|
163
|
+
- Create: `js/src/lib/csv/extractors.ts`
|
|
164
|
+
- Modify: `js/src/lib/csv/index.ts`
|
|
165
|
+
|
|
166
|
+
**Step 1: Create the extractors module**
|
|
167
|
+
|
|
168
|
+
Create `js/src/lib/csv/extractors.ts`:
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
/**
|
|
172
|
+
* CSV data extractors for each run type
|
|
173
|
+
*/
|
|
174
|
+
import type {
|
|
175
|
+
DataFrame,
|
|
176
|
+
QueryDiffResult,
|
|
177
|
+
ValueDiffResult,
|
|
178
|
+
ProfileDiffResult,
|
|
179
|
+
RowCountDiffResult,
|
|
180
|
+
TopKDiffResult,
|
|
181
|
+
} from "@/lib/api/types";
|
|
182
|
+
|
|
183
|
+
export interface CSVData {
|
|
184
|
+
columns: string[];
|
|
185
|
+
rows: unknown[][];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Extract columns and rows from a DataFrame
|
|
190
|
+
*/
|
|
191
|
+
function extractDataFrame(df: DataFrame | undefined): CSVData | null {
|
|
192
|
+
if (!df || !df.columns || !df.data) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
columns: df.columns.map((col) => col.name),
|
|
197
|
+
rows: df.data.map((row) => [...row]),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Extract CSV data from query result (single environment)
|
|
203
|
+
*/
|
|
204
|
+
function extractQuery(result: unknown): CSVData | null {
|
|
205
|
+
return extractDataFrame(result as DataFrame);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Extract CSV data from query_base result
|
|
210
|
+
*/
|
|
211
|
+
function extractQueryBase(result: unknown): CSVData | null {
|
|
212
|
+
const typed = result as { base?: DataFrame };
|
|
213
|
+
return extractDataFrame(typed?.base);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Extract CSV data from query_diff result
|
|
218
|
+
* Combines base and current with a source column
|
|
219
|
+
*/
|
|
220
|
+
function extractQueryDiff(result: unknown): CSVData | null {
|
|
221
|
+
const typed = result as QueryDiffResult;
|
|
222
|
+
|
|
223
|
+
// Prefer current, fall back to base
|
|
224
|
+
const df = typed?.current || typed?.base;
|
|
225
|
+
if (!df) return null;
|
|
226
|
+
|
|
227
|
+
// If both exist, combine them
|
|
228
|
+
if (typed?.base && typed?.current) {
|
|
229
|
+
const baseColumns = typed.base.columns.map((c) => c.name);
|
|
230
|
+
const currentColumns = typed.current.columns.map((c) => c.name);
|
|
231
|
+
|
|
232
|
+
// Use current columns as the standard
|
|
233
|
+
const columns = ["_source", ...currentColumns];
|
|
234
|
+
const rows: unknown[][] = [];
|
|
235
|
+
|
|
236
|
+
// Add base rows
|
|
237
|
+
typed.base.data.forEach((row) => {
|
|
238
|
+
rows.push(["base", ...row]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Add current rows
|
|
242
|
+
typed.current.data.forEach((row) => {
|
|
243
|
+
rows.push(["current", ...row]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return { columns, rows };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return extractDataFrame(df);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Extract CSV data from profile_diff result
|
|
254
|
+
*/
|
|
255
|
+
function extractProfileDiff(result: unknown): CSVData | null {
|
|
256
|
+
const typed = result as ProfileDiffResult;
|
|
257
|
+
|
|
258
|
+
// Profile data has metrics as columns, one row per profiled column
|
|
259
|
+
const df = typed?.current || typed?.base;
|
|
260
|
+
if (!df) return null;
|
|
261
|
+
|
|
262
|
+
// If both exist, combine with source column
|
|
263
|
+
if (typed?.base && typed?.current) {
|
|
264
|
+
const columns = ["_source", ...typed.current.columns.map((c) => c.name)];
|
|
265
|
+
const rows: unknown[][] = [];
|
|
266
|
+
|
|
267
|
+
typed.base.data.forEach((row) => {
|
|
268
|
+
rows.push(["base", ...row]);
|
|
269
|
+
});
|
|
270
|
+
typed.current.data.forEach((row) => {
|
|
271
|
+
rows.push(["current", ...row]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return { columns, rows };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return extractDataFrame(df);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Extract CSV data from row_count_diff result
|
|
282
|
+
*/
|
|
283
|
+
function extractRowCountDiff(result: unknown): CSVData | null {
|
|
284
|
+
const typed = result as RowCountDiffResult;
|
|
285
|
+
if (!typed || typeof typed !== "object") return null;
|
|
286
|
+
|
|
287
|
+
const columns = ["node", "base_count", "current_count", "diff", "diff_percent"];
|
|
288
|
+
const rows: unknown[][] = [];
|
|
289
|
+
|
|
290
|
+
for (const [nodeName, counts] of Object.entries(typed)) {
|
|
291
|
+
if (counts && typeof counts === "object") {
|
|
292
|
+
const base = (counts as { base?: number }).base;
|
|
293
|
+
const current = (counts as { curr?: number }).curr;
|
|
294
|
+
const diff = base !== undefined && current !== undefined ? current - base : null;
|
|
295
|
+
const diffPercent = base && diff !== null ? ((diff / base) * 100).toFixed(2) + "%" : null;
|
|
296
|
+
rows.push([nodeName, base, current, diff, diffPercent]);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { columns, rows };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Extract CSV data from value_diff result
|
|
305
|
+
*/
|
|
306
|
+
function extractValueDiff(result: unknown): CSVData | null {
|
|
307
|
+
const typed = result as ValueDiffResult;
|
|
308
|
+
if (!typed?.data) return null;
|
|
309
|
+
return extractDataFrame(typed.data);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Extract CSV data from value_diff_detail result
|
|
314
|
+
*/
|
|
315
|
+
function extractValueDiffDetail(result: unknown): CSVData | null {
|
|
316
|
+
return extractDataFrame(result as DataFrame);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Extract CSV data from top_k_diff result
|
|
321
|
+
*/
|
|
322
|
+
function extractTopKDiff(result: unknown): CSVData | null {
|
|
323
|
+
const typed = result as TopKDiffResult;
|
|
324
|
+
|
|
325
|
+
// Prefer current, fall back to base
|
|
326
|
+
const topK = typed?.current || typed?.base;
|
|
327
|
+
if (!topK?.valids) return null;
|
|
328
|
+
|
|
329
|
+
// TopK has { valids: [{ value, count }], nulls: number }
|
|
330
|
+
const columns = ["_source", "value", "count"];
|
|
331
|
+
const rows: unknown[][] = [];
|
|
332
|
+
|
|
333
|
+
if (typed?.base?.valids) {
|
|
334
|
+
typed.base.valids.forEach((item) => {
|
|
335
|
+
rows.push(["base", item.value, item.count]);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (typed?.current?.valids) {
|
|
339
|
+
typed.current.valids.forEach((item) => {
|
|
340
|
+
rows.push(["current", item.value, item.count]);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { columns, rows };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Map of run types to their extractor functions
|
|
349
|
+
*/
|
|
350
|
+
const extractors: Record<string, (result: unknown) => CSVData | null> = {
|
|
351
|
+
query: extractQuery,
|
|
352
|
+
query_base: extractQueryBase,
|
|
353
|
+
query_diff: extractQueryDiff,
|
|
354
|
+
profile: extractProfileDiff,
|
|
355
|
+
profile_diff: extractProfileDiff,
|
|
356
|
+
row_count: extractRowCountDiff,
|
|
357
|
+
row_count_diff: extractRowCountDiff,
|
|
358
|
+
value_diff: extractValueDiff,
|
|
359
|
+
value_diff_detail: extractValueDiffDetail,
|
|
360
|
+
top_k_diff: extractTopKDiff,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Extract CSV data from a run result
|
|
365
|
+
* @returns CSVData or null if the run type doesn't support CSV export
|
|
366
|
+
*/
|
|
367
|
+
export function extractCSVData(
|
|
368
|
+
runType: string,
|
|
369
|
+
result: unknown
|
|
370
|
+
): CSVData | null {
|
|
371
|
+
const extractor = extractors[runType];
|
|
372
|
+
if (!extractor) return null;
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
return extractor(result);
|
|
376
|
+
} catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Check if a run type supports CSV export
|
|
383
|
+
*/
|
|
384
|
+
export function supportsCSVExport(runType: string): boolean {
|
|
385
|
+
return runType in extractors;
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**Step 2: Export extractors from index**
|
|
390
|
+
|
|
391
|
+
Modify `js/src/lib/csv/index.ts` - add at the end:
|
|
392
|
+
|
|
393
|
+
```typescript
|
|
394
|
+
export { extractCSVData, supportsCSVExport, type CSVData } from "./extractors";
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
**Step 3: Verify the module compiles**
|
|
398
|
+
|
|
399
|
+
Run: `cd /Users/kliu/recceAll/recce && pnpm exec tsc --noEmit js/src/lib/csv/*.ts 2>&1 | head -30`
|
|
400
|
+
|
|
401
|
+
Expected: No errors
|
|
402
|
+
|
|
403
|
+
**Step 4: Commit**
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
cd /Users/kliu/recceAll/recce
|
|
407
|
+
git add js/src/lib/csv/
|
|
408
|
+
git commit -m "feat(csv): add CSV data extractors for all tabular run types"
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
## Task 3: Add CSV Export Hook
|
|
414
|
+
|
|
415
|
+
**Files:**
|
|
416
|
+
- Create: `js/src/lib/hooks/useCSVExport.ts`
|
|
417
|
+
|
|
418
|
+
**Step 1: Create the CSV export hook**
|
|
419
|
+
|
|
420
|
+
Create `js/src/lib/hooks/useCSVExport.ts`:
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
/**
|
|
424
|
+
* Hook for CSV export functionality
|
|
425
|
+
*/
|
|
426
|
+
import { useCallback, useMemo } from "react";
|
|
427
|
+
import { toaster } from "@/components/ui/toaster";
|
|
428
|
+
import {
|
|
429
|
+
toCSV,
|
|
430
|
+
downloadCSV,
|
|
431
|
+
copyCSVToClipboard,
|
|
432
|
+
generateCSVFilename,
|
|
433
|
+
extractCSVData,
|
|
434
|
+
supportsCSVExport,
|
|
435
|
+
} from "@/lib/csv";
|
|
436
|
+
import type { Run } from "@/lib/api/types";
|
|
437
|
+
|
|
438
|
+
interface UseCSVExportOptions {
|
|
439
|
+
run?: Run;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
interface UseCSVExportResult {
|
|
443
|
+
/** Whether CSV export is available for this run type */
|
|
444
|
+
canExportCSV: boolean;
|
|
445
|
+
/** Copy result data as CSV to clipboard */
|
|
446
|
+
copyAsCSV: () => Promise<void>;
|
|
447
|
+
/** Download result data as CSV file */
|
|
448
|
+
downloadAsCSV: () => void;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function useCSVExport({ run }: UseCSVExportOptions): UseCSVExportResult {
|
|
452
|
+
const canExportCSV = useMemo(() => {
|
|
453
|
+
if (!run?.type || !run?.result) return false;
|
|
454
|
+
return supportsCSVExport(run.type);
|
|
455
|
+
}, [run?.type, run?.result]);
|
|
456
|
+
|
|
457
|
+
const getCSVContent = useCallback((): string | null => {
|
|
458
|
+
if (!run?.type || !run?.result) return null;
|
|
459
|
+
|
|
460
|
+
const csvData = extractCSVData(run.type, run.result);
|
|
461
|
+
if (!csvData) return null;
|
|
462
|
+
|
|
463
|
+
return toCSV(csvData.columns, csvData.rows);
|
|
464
|
+
}, [run?.type, run?.result]);
|
|
465
|
+
|
|
466
|
+
const copyAsCSV = useCallback(async () => {
|
|
467
|
+
const content = getCSVContent();
|
|
468
|
+
if (!content) {
|
|
469
|
+
toaster.create({
|
|
470
|
+
title: "Export failed",
|
|
471
|
+
description: "Unable to extract data for CSV export",
|
|
472
|
+
type: "error",
|
|
473
|
+
duration: 3000,
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
await copyCSVToClipboard(content);
|
|
480
|
+
toaster.create({
|
|
481
|
+
title: "Copied to clipboard",
|
|
482
|
+
description: "CSV data copied successfully",
|
|
483
|
+
type: "success",
|
|
484
|
+
duration: 2000,
|
|
485
|
+
});
|
|
486
|
+
} catch (error) {
|
|
487
|
+
toaster.create({
|
|
488
|
+
title: "Copy failed",
|
|
489
|
+
description: "Failed to copy to clipboard",
|
|
490
|
+
type: "error",
|
|
491
|
+
duration: 3000,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}, [getCSVContent]);
|
|
495
|
+
|
|
496
|
+
const downloadAsCSV = useCallback(() => {
|
|
497
|
+
const content = getCSVContent();
|
|
498
|
+
if (!content) {
|
|
499
|
+
toaster.create({
|
|
500
|
+
title: "Export failed",
|
|
501
|
+
description: "Unable to extract data for CSV export",
|
|
502
|
+
type: "error",
|
|
503
|
+
duration: 3000,
|
|
504
|
+
});
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
const filename = generateCSVFilename(run!.type, run!.params as Record<string, unknown>);
|
|
510
|
+
downloadCSV(content, filename);
|
|
511
|
+
toaster.create({
|
|
512
|
+
title: "Downloaded",
|
|
513
|
+
description: filename,
|
|
514
|
+
type: "success",
|
|
515
|
+
duration: 3000,
|
|
516
|
+
});
|
|
517
|
+
} catch (error) {
|
|
518
|
+
toaster.create({
|
|
519
|
+
title: "Download failed",
|
|
520
|
+
description: "Failed to download CSV file",
|
|
521
|
+
type: "error",
|
|
522
|
+
duration: 3000,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}, [getCSVContent, run]);
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
canExportCSV,
|
|
529
|
+
copyAsCSV,
|
|
530
|
+
downloadAsCSV,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
**Step 2: Verify the hook compiles**
|
|
536
|
+
|
|
537
|
+
Run: `cd /Users/kliu/recceAll/recce && pnpm exec tsc --noEmit js/src/lib/hooks/useCSVExport.ts 2>&1 | head -20`
|
|
538
|
+
|
|
539
|
+
Expected: No errors
|
|
540
|
+
|
|
541
|
+
**Step 3: Commit**
|
|
542
|
+
|
|
543
|
+
```bash
|
|
544
|
+
cd /Users/kliu/recceAll/recce
|
|
545
|
+
git add js/src/lib/hooks/useCSVExport.ts
|
|
546
|
+
git commit -m "feat(csv): add useCSVExport hook for CSV operations"
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
## Task 4: Refactor RunResultShareMenu Component
|
|
552
|
+
|
|
553
|
+
**Files:**
|
|
554
|
+
- Modify: `js/src/components/run/RunResultPane.tsx`
|
|
555
|
+
|
|
556
|
+
**Step 1: Update imports**
|
|
557
|
+
|
|
558
|
+
Add these imports at the top of `js/src/components/run/RunResultPane.tsx`:
|
|
559
|
+
|
|
560
|
+
```typescript
|
|
561
|
+
import { PiDownloadSimple, PiImage, PiTable } from "react-icons/pi";
|
|
562
|
+
import { useCSVExport } from "@/lib/hooks/useCSVExport";
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
**Step 2: Update RunResultShareMenu props interface**
|
|
566
|
+
|
|
567
|
+
Find the `RunResultShareMenu` component (around line 116) and update its props:
|
|
568
|
+
|
|
569
|
+
```typescript
|
|
570
|
+
const RunResultShareMenu = ({
|
|
571
|
+
run,
|
|
572
|
+
disableCopyToClipboard,
|
|
573
|
+
onCopyToClipboard,
|
|
574
|
+
onMouseEnter,
|
|
575
|
+
onMouseLeave,
|
|
576
|
+
}: {
|
|
577
|
+
run?: Run;
|
|
578
|
+
disableCopyToClipboard: boolean;
|
|
579
|
+
onCopyToClipboard: () => Promise<void>;
|
|
580
|
+
onMouseEnter: () => void;
|
|
581
|
+
onMouseLeave: () => void;
|
|
582
|
+
}) => {
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
**Step 3: Add CSV export hook inside RunResultShareMenu**
|
|
586
|
+
|
|
587
|
+
Inside the `RunResultShareMenu` component, after the existing state declarations, add:
|
|
588
|
+
|
|
589
|
+
```typescript
|
|
590
|
+
const { canExportCSV, copyAsCSV, downloadAsCSV } = useCSVExport({ run });
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
**Step 4: Update menu items**
|
|
594
|
+
|
|
595
|
+
Replace the existing menu content (the `<Menu>` element and its children) with:
|
|
596
|
+
|
|
597
|
+
```typescript
|
|
598
|
+
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
|
|
599
|
+
<MenuItem
|
|
600
|
+
onClick={async () => {
|
|
601
|
+
await onCopyToClipboard();
|
|
602
|
+
handleClose();
|
|
603
|
+
}}
|
|
604
|
+
onMouseEnter={onMouseEnter}
|
|
605
|
+
onMouseLeave={onMouseLeave}
|
|
606
|
+
disabled={disableCopyToClipboard}
|
|
607
|
+
>
|
|
608
|
+
<ListItemIcon>
|
|
609
|
+
<PiImage />
|
|
610
|
+
</ListItemIcon>
|
|
611
|
+
<ListItemText>Copy as Image</ListItemText>
|
|
612
|
+
</MenuItem>
|
|
613
|
+
<MenuItem
|
|
614
|
+
onClick={async () => {
|
|
615
|
+
await copyAsCSV();
|
|
616
|
+
handleClose();
|
|
617
|
+
}}
|
|
618
|
+
disabled={disableCopyToClipboard || !canExportCSV}
|
|
619
|
+
>
|
|
620
|
+
<ListItemIcon>
|
|
621
|
+
<PiTable />
|
|
622
|
+
</ListItemIcon>
|
|
623
|
+
<ListItemText>Copy as CSV</ListItemText>
|
|
624
|
+
</MenuItem>
|
|
625
|
+
<MenuItem
|
|
626
|
+
onClick={() => {
|
|
627
|
+
downloadAsCSV();
|
|
628
|
+
handleClose();
|
|
629
|
+
}}
|
|
630
|
+
disabled={disableCopyToClipboard || !canExportCSV}
|
|
631
|
+
>
|
|
632
|
+
<ListItemIcon>
|
|
633
|
+
<PiDownloadSimple />
|
|
634
|
+
</ListItemIcon>
|
|
635
|
+
<ListItemText>Download as CSV</ListItemText>
|
|
636
|
+
</MenuItem>
|
|
637
|
+
<Divider />
|
|
638
|
+
{authed ? (
|
|
639
|
+
<MenuItem
|
|
640
|
+
onClick={async () => {
|
|
641
|
+
await handleShareClick();
|
|
642
|
+
trackShareState({ name: "create" });
|
|
643
|
+
handleClose();
|
|
644
|
+
}}
|
|
645
|
+
>
|
|
646
|
+
<ListItemIcon>
|
|
647
|
+
<TbCloudUpload />
|
|
648
|
+
</ListItemIcon>
|
|
649
|
+
<ListItemText>Share to Cloud</ListItemText>
|
|
650
|
+
</MenuItem>
|
|
651
|
+
) : (
|
|
652
|
+
<MenuItem
|
|
653
|
+
onClick={() => {
|
|
654
|
+
setShowModal(true);
|
|
655
|
+
handleClose();
|
|
656
|
+
}}
|
|
657
|
+
>
|
|
658
|
+
<ListItemIcon>
|
|
659
|
+
<TbCloudUpload />
|
|
660
|
+
</ListItemIcon>
|
|
661
|
+
<ListItemText>Share</ListItemText>
|
|
662
|
+
</MenuItem>
|
|
663
|
+
)}
|
|
664
|
+
</Menu>
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
**Step 5: Update RunResultShareMenu usage**
|
|
668
|
+
|
|
669
|
+
Find where `RunResultShareMenu` is used (around line 308) and add the `run` prop:
|
|
670
|
+
|
|
671
|
+
```typescript
|
|
672
|
+
<RunResultShareMenu
|
|
673
|
+
run={run}
|
|
674
|
+
disableCopyToClipboard={disableCopyToClipboard}
|
|
675
|
+
onCopyToClipboard={async () => {
|
|
676
|
+
await onCopyToClipboard();
|
|
677
|
+
trackCopyToClipboard({
|
|
678
|
+
type: run?.type ?? "unknown",
|
|
679
|
+
from: "run",
|
|
680
|
+
});
|
|
681
|
+
}}
|
|
682
|
+
onMouseEnter={onMouseEnter}
|
|
683
|
+
onMouseLeave={onMouseLeave}
|
|
684
|
+
/>
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
**Step 6: Verify the file compiles**
|
|
688
|
+
|
|
689
|
+
Run: `cd /Users/kliu/recceAll/recce && pnpm exec tsc --noEmit js/src/components/run/RunResultPane.tsx 2>&1 | head -30`
|
|
690
|
+
|
|
691
|
+
Expected: No errors
|
|
692
|
+
|
|
693
|
+
**Step 7: Commit**
|
|
694
|
+
|
|
695
|
+
```bash
|
|
696
|
+
cd /Users/kliu/recceAll/recce
|
|
697
|
+
git add js/src/components/run/RunResultPane.tsx
|
|
698
|
+
git commit -m "feat(csv): add CSV export options to Share menu"
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
---
|
|
702
|
+
|
|
703
|
+
## Task 5: Update Standalone Copy Button (when share disabled)
|
|
704
|
+
|
|
705
|
+
**Files:**
|
|
706
|
+
- Modify: `js/src/components/run/RunResultPane.tsx`
|
|
707
|
+
|
|
708
|
+
**Step 1: Convert standalone button to menu**
|
|
709
|
+
|
|
710
|
+
Find the standalone button section (around line 291-306, the `featureToggles.disableShare` branch) and replace it with a menu similar to `RunResultShareMenu` but without the Share to Cloud option.
|
|
711
|
+
|
|
712
|
+
Replace this code block:
|
|
713
|
+
|
|
714
|
+
```typescript
|
|
715
|
+
{featureToggles.disableShare ? (
|
|
716
|
+
<Button
|
|
717
|
+
variant="outlined"
|
|
718
|
+
color="neutral"
|
|
719
|
+
disabled={
|
|
720
|
+
!runId || !run?.result || !!error || tabValue !== "result"
|
|
721
|
+
}
|
|
722
|
+
onMouseEnter={onMouseEnter}
|
|
723
|
+
onMouseLeave={onMouseLeave}
|
|
724
|
+
size="small"
|
|
725
|
+
onClick={onCopyToClipboard}
|
|
726
|
+
startIcon={<PiCopy />}
|
|
727
|
+
sx={{ textTransform: "none" }}
|
|
728
|
+
>
|
|
729
|
+
Copy to Clipboard
|
|
730
|
+
</Button>
|
|
731
|
+
) : (
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
With:
|
|
735
|
+
|
|
736
|
+
```typescript
|
|
737
|
+
{featureToggles.disableShare ? (
|
|
738
|
+
<RunResultExportMenu
|
|
739
|
+
run={run}
|
|
740
|
+
disableExport={!runId || !run?.result || !!error || tabValue !== "result"}
|
|
741
|
+
onCopyAsImage={onCopyToClipboard}
|
|
742
|
+
onMouseEnter={onMouseEnter}
|
|
743
|
+
onMouseLeave={onMouseLeave}
|
|
744
|
+
/>
|
|
745
|
+
) : (
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
**Step 2: Create RunResultExportMenu component**
|
|
749
|
+
|
|
750
|
+
Add this new component before `RunResultShareMenu` (around line 115):
|
|
751
|
+
|
|
752
|
+
```typescript
|
|
753
|
+
const RunResultExportMenu = ({
|
|
754
|
+
run,
|
|
755
|
+
disableExport,
|
|
756
|
+
onCopyAsImage,
|
|
757
|
+
onMouseEnter,
|
|
758
|
+
onMouseLeave,
|
|
759
|
+
}: {
|
|
760
|
+
run?: Run;
|
|
761
|
+
disableExport: boolean;
|
|
762
|
+
onCopyAsImage: () => Promise<void>;
|
|
763
|
+
onMouseEnter: () => void;
|
|
764
|
+
onMouseLeave: () => void;
|
|
765
|
+
}) => {
|
|
766
|
+
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
|
767
|
+
const open = Boolean(anchorEl);
|
|
768
|
+
const { canExportCSV, copyAsCSV, downloadAsCSV } = useCSVExport({ run });
|
|
769
|
+
|
|
770
|
+
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
|
771
|
+
setAnchorEl(event.currentTarget);
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const handleClose = () => {
|
|
775
|
+
setAnchorEl(null);
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
return (
|
|
779
|
+
<>
|
|
780
|
+
<Button
|
|
781
|
+
size="small"
|
|
782
|
+
variant="outlined"
|
|
783
|
+
color="neutral"
|
|
784
|
+
onClick={handleClick}
|
|
785
|
+
endIcon={<PiCaretDown />}
|
|
786
|
+
sx={{ textTransform: "none" }}
|
|
787
|
+
>
|
|
788
|
+
Export
|
|
789
|
+
</Button>
|
|
790
|
+
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
|
|
791
|
+
<MenuItem
|
|
792
|
+
onClick={async () => {
|
|
793
|
+
await onCopyAsImage();
|
|
794
|
+
handleClose();
|
|
795
|
+
}}
|
|
796
|
+
onMouseEnter={onMouseEnter}
|
|
797
|
+
onMouseLeave={onMouseLeave}
|
|
798
|
+
disabled={disableExport}
|
|
799
|
+
>
|
|
800
|
+
<ListItemIcon>
|
|
801
|
+
<PiImage />
|
|
802
|
+
</ListItemIcon>
|
|
803
|
+
<ListItemText>Copy as Image</ListItemText>
|
|
804
|
+
</MenuItem>
|
|
805
|
+
<MenuItem
|
|
806
|
+
onClick={async () => {
|
|
807
|
+
await copyAsCSV();
|
|
808
|
+
handleClose();
|
|
809
|
+
}}
|
|
810
|
+
disabled={disableExport || !canExportCSV}
|
|
811
|
+
>
|
|
812
|
+
<ListItemIcon>
|
|
813
|
+
<PiTable />
|
|
814
|
+
</ListItemIcon>
|
|
815
|
+
<ListItemText>Copy as CSV</ListItemText>
|
|
816
|
+
</MenuItem>
|
|
817
|
+
<MenuItem
|
|
818
|
+
onClick={() => {
|
|
819
|
+
downloadAsCSV();
|
|
820
|
+
handleClose();
|
|
821
|
+
}}
|
|
822
|
+
disabled={disableExport || !canExportCSV}
|
|
823
|
+
>
|
|
824
|
+
<ListItemIcon>
|
|
825
|
+
<PiDownloadSimple />
|
|
826
|
+
</ListItemIcon>
|
|
827
|
+
<ListItemText>Download as CSV</ListItemText>
|
|
828
|
+
</MenuItem>
|
|
829
|
+
</Menu>
|
|
830
|
+
</>
|
|
831
|
+
);
|
|
832
|
+
};
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
**Step 3: Verify the file compiles**
|
|
836
|
+
|
|
837
|
+
Run: `cd /Users/kliu/recceAll/recce && pnpm exec tsc --noEmit js/src/components/run/RunResultPane.tsx 2>&1 | head -30`
|
|
838
|
+
|
|
839
|
+
Expected: No errors
|
|
840
|
+
|
|
841
|
+
**Step 4: Commit**
|
|
842
|
+
|
|
843
|
+
```bash
|
|
844
|
+
cd /Users/kliu/recceAll/recce
|
|
845
|
+
git add js/src/components/run/RunResultPane.tsx
|
|
846
|
+
git commit -m "feat(csv): add Export menu when share is disabled"
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
---
|
|
850
|
+
|
|
851
|
+
## Task 6: Manual Testing
|
|
852
|
+
|
|
853
|
+
**Step 1: Start the development server**
|
|
854
|
+
|
|
855
|
+
Run: `cd /Users/kliu/recceAll/recce && pnpm dev`
|
|
856
|
+
|
|
857
|
+
**Step 2: Test CSV export for query results**
|
|
858
|
+
|
|
859
|
+
1. Navigate to a query run result
|
|
860
|
+
2. Click the "Share" dropdown menu
|
|
861
|
+
3. Verify "Copy as Image", "Copy as CSV", "Download as CSV" options appear
|
|
862
|
+
4. Click "Copy as CSV" - verify toast shows success
|
|
863
|
+
5. Paste in text editor - verify CSV format with headers and data
|
|
864
|
+
6. Click "Download as CSV" - verify file downloads with correct filename
|
|
865
|
+
|
|
866
|
+
**Step 3: Test CSV export for other result types**
|
|
867
|
+
|
|
868
|
+
Test each supported type:
|
|
869
|
+
- query_diff
|
|
870
|
+
- profile / profile_diff
|
|
871
|
+
- row_count / row_count_diff
|
|
872
|
+
- value_diff / value_diff_detail
|
|
873
|
+
- top_k_diff
|
|
874
|
+
|
|
875
|
+
**Step 4: Test disabled states**
|
|
876
|
+
|
|
877
|
+
1. For non-tabular results (histogram, lineage), verify CSV options are disabled
|
|
878
|
+
2. When no result is available, verify all options are disabled
|
|
879
|
+
3. When not on "Result" tab, verify options are disabled
|
|
880
|
+
|
|
881
|
+
**Step 5: Test Export menu (when share disabled)**
|
|
882
|
+
|
|
883
|
+
1. Set `featureToggles.disableShare = true` in dev environment
|
|
884
|
+
2. Verify "Export" button appears instead of "Share"
|
|
885
|
+
3. Verify menu has same options minus "Share to Cloud"
|
|
886
|
+
|
|
887
|
+
---
|
|
888
|
+
|
|
889
|
+
## Task 7: Final Review and Cleanup
|
|
890
|
+
|
|
891
|
+
**Step 1: Run linter**
|
|
892
|
+
|
|
893
|
+
Run: `cd /Users/kliu/recceAll/recce && pnpm lint`
|
|
894
|
+
|
|
895
|
+
Fix any linting errors.
|
|
896
|
+
|
|
897
|
+
**Step 2: Run type check**
|
|
898
|
+
|
|
899
|
+
Run: `cd /Users/kliu/recceAll/recce && pnpm exec tsc --noEmit`
|
|
900
|
+
|
|
901
|
+
Fix any type errors.
|
|
902
|
+
|
|
903
|
+
**Step 3: Run tests**
|
|
904
|
+
|
|
905
|
+
Run: `cd /Users/kliu/recceAll/recce && pnpm test`
|
|
906
|
+
|
|
907
|
+
Fix any failing tests.
|
|
908
|
+
|
|
909
|
+
**Step 4: Final commit**
|
|
910
|
+
|
|
911
|
+
```bash
|
|
912
|
+
cd /Users/kliu/recceAll/recce
|
|
913
|
+
git add -A
|
|
914
|
+
git commit -m "chore: fix linting and type errors for CSV export feature"
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
---
|
|
918
|
+
|
|
919
|
+
## Summary
|
|
920
|
+
|
|
921
|
+
Files created:
|
|
922
|
+
- `js/src/lib/csv/index.ts` - CSV export utilities
|
|
923
|
+
- `js/src/lib/csv/format.ts` - CSV formatting
|
|
924
|
+
- `js/src/lib/csv/extractors.ts` - Data extractors per run type
|
|
925
|
+
- `js/src/lib/hooks/useCSVExport.ts` - React hook for CSV operations
|
|
926
|
+
|
|
927
|
+
Files modified:
|
|
928
|
+
- `js/src/components/run/RunResultPane.tsx` - Menu refactoring
|
|
929
|
+
|
|
930
|
+
Total commits: 6
|