diff --git a/src/services/salesforce/bulk-api.ts b/src/services/salesforce/bulk-api.ts index 7d915bc..946dcf8 100644 --- a/src/services/salesforce/bulk-api.ts +++ b/src/services/salesforce/bulk-api.ts @@ -244,6 +244,7 @@ export class BulkApiService { header: true, delimiter: ',', skipEmptyLines: true, + dynamicTyping: true, transformHeader: header => header.trim(), }); if (parsed.errors.length > 0) { diff --git a/src/services/salesforce/queryAll.ts b/src/services/salesforce/queryAll.ts index 3eb708c..430a1cd 100644 --- a/src/services/salesforce/queryAll.ts +++ b/src/services/salesforce/queryAll.ts @@ -50,18 +50,28 @@ export async function queryAllRecords( } /** - * Derives the set of column names present across the first `sampleSize` records, - * excluding Salesforce's `attributes` envelope. Stable order of first appearance. + * Derives the set of column names present across all loaded records, + * excluding Salesforce's `attributes` envelope at any nesting level. + * Stable order of first appearance. */ -export function deriveColumns(records: Record[], sampleSize = 50): string[] { +export function deriveColumns(records: Record[]): string[] { const cols: string[] = []; const seen = new Set(); - for (const record of records.slice(0, sampleSize)) { - for (const key of Object.keys(record)) { - if (key === 'attributes' || seen.has(key)) continue; - seen.add(key); - cols.push(key); - } + for (const record of records) { + collectKeys(record, '', seen, cols); } return cols; } + +function collectKeys(value: unknown, prefix: string, seen: Set, cols: string[]): void { + if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) return; + for (const [k, v] of Object.entries(value as Record)) { + if (k === 'attributes') continue; + const fullKey = prefix ? `${prefix}.${k}` : k; + if (!seen.has(fullKey)) { + seen.add(fullKey); + cols.push(fullKey); + } + collectKeys(v, fullKey, seen, cols); + } +} diff --git a/src/ui/components/ExportModal.tsx b/src/ui/components/ExportModal.tsx index 0eee046..c11e1b0 100644 --- a/src/ui/components/ExportModal.tsx +++ b/src/ui/components/ExportModal.tsx @@ -15,6 +15,7 @@ export interface ExportModalProps { open: boolean; records: Record[]; columns: string[]; + selectedColumns?: string[]; defaultFilename?: string; preferences?: ExportPreferences; onPreferencesChange?: (preferences: ExportPreferences) => void; @@ -34,16 +35,18 @@ export function ExportModal(props: ExportModalProps): VNode | null { const [filename, setFilename] = useState(defaultFilename); const [sheetName, setSheetName] = useState(props.preferences?.sheetName ?? 'Sheet1'); const [includeMetadata, setIncludeMetadata] = useState(props.preferences?.includeMetadata ?? false); - const [selectedColumns, setSelectedColumns] = useState>(new Set(columns)); + const [selectedColumns, setSelectedColumns] = useState>(new Set(props.selectedColumns ?? columns)); const [showColumnPicker, setShowColumnPicker] = useState(false); + const [error, setError] = useState(null); useEffect(() => { if (!open) return; - setSelectedColumns(new Set(columns)); + setSelectedColumns(new Set(props.selectedColumns ?? columns)); setFormat(props.preferences?.format ?? 'csv'); setSheetName(props.preferences?.sheetName ?? 'Sheet1'); setIncludeMetadata(props.preferences?.includeMetadata ?? false); - }, [open, columns, props.preferences?.format, props.preferences?.sheetName, props.preferences?.includeMetadata]); + setError(null); + }, [open, columns, props.selectedColumns, props.preferences?.format, props.preferences?.sheetName, props.preferences?.includeMetadata]); function updatePreferences(next: Partial): void { props.onPreferencesChange?.({ format, sheetName, includeMetadata, ...next }); @@ -52,17 +55,39 @@ export function ExportModal(props: ExportModalProps): VNode | null { if (!open) return null; const handleExport = async () => { + setError(null); + + // Validate Excel sheet name: max 31 chars, no : \ / ? * [ ] + if (format === 'excel') { + const invalidChars = /[:\\/?*\[\]]/; + if (!sheetName.trim()) { + setError('Sheet name cannot be empty.'); + return; + } + if (sheetName.length > 31) { + setError('Sheet name must be 31 characters or fewer.'); + return; + } + if (invalidChars.test(sheetName)) { + setError('Sheet name cannot contain : \\ / ? * [ ]'); + return; + } + } + const finalFilename = ensureCorrectExtension(filename, format); const columnsToExport = Array.from(selectedColumns); - await exportRecords(records, columnsToExport, { - format, - filename: finalFilename, - sheetName: format === 'excel' ? sheetName : undefined, - includeMetadata: format === 'json' ? includeMetadata : undefined, - }); - - onClose(); + try { + await exportRecords(records, columnsToExport, { + format, + filename: finalFilename, + sheetName: format === 'excel' ? sheetName : undefined, + includeMetadata: format === 'json' ? includeMetadata : undefined, + }); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } }; const toggleColumn = (column: string) => { @@ -217,6 +242,12 @@ export function ExportModal(props: ExportModalProps): VNode | null { )} + {error ? ( +
+ {error} +
+ ) : null} +