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
1 change: 1 addition & 0 deletions src/services/salesforce/bulk-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export class BulkApiService {
header: true,
delimiter: ',',
skipEmptyLines: true,
dynamicTyping: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve text and datetime values when parsing bulk CSV

Enabling dynamicTyping for every column makes Papa Parse infer types solely from CSV contents, without Salesforce field metadata. Consequently, text values such as postal codes or external IDs containing 00123 become numbers and lose leading zeroes, while ISO datetime strings become Date objects; those Date objects are subsequently omitted by flattenRecord because it traverses them with Object.entries. Bulk-query exports can therefore silently corrupt text fields or entirely lose datetime columns, so conversion needs to be schema-aware or the CSV values should remain strings.

Useful? React with 👍 / 👎.

transformHeader: header => header.trim(),
});
if (parsed.errors.length > 0) {
Expand Down
28 changes: 19 additions & 9 deletions src/services/salesforce/queryAll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[], sampleSize = 50): string[] {
export function deriveColumns(records: Record<string, unknown>[]): string[] {
const cols: string[] = [];
const seen = new Set<string>();
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<string>, 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<string, unknown>)) {
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);
Comment on lines +71 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Flatten snapshot rows before adding dotted columns

For scheduled queries containing relationships, this adds headers such as Account.Name, but runSchedule stores the original nested records and later calls exportRecords(snapshot.records, snapshot.columns, ...); every exporter reads values with record[column]. Since the raw row has record.Account.Name rather than record['Account.Name'], the new dotted columns are blank in downloaded snapshots, while the added parent Account column still serializes the object incorrectly. Flatten the records before storing/exporting them, or resolve dotted paths instead of emitting unmatched headers.

Useful? React with 👍 / 👎.

}
}
53 changes: 42 additions & 11 deletions src/ui/components/ExportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface ExportModalProps {
open: boolean;
records: Record<string, unknown>[];
columns: string[];
selectedColumns?: string[];
defaultFilename?: string;
preferences?: ExportPreferences;
onPreferencesChange?: (preferences: ExportPreferences) => void;
Expand All @@ -34,16 +35,18 @@ export function ExportModal(props: ExportModalProps): VNode | null {
const [filename, setFilename] = useState<string>(defaultFilename);
const [sheetName, setSheetName] = useState<string>(props.preferences?.sheetName ?? 'Sheet1');
const [includeMetadata, setIncludeMetadata] = useState<boolean>(props.preferences?.includeMetadata ?? false);
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set(columns));
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set(props.selectedColumns ?? columns));
const [showColumnPicker, setShowColumnPicker] = useState<boolean>(false);
const [error, setError] = useState<string | null>(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<ExportPreferences>): void {
props.onPreferencesChange?.({ format, sheetName, includeMetadata, ...next });
Expand All @@ -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) => {
Expand Down Expand Up @@ -217,6 +242,12 @@ export function ExportModal(props: ExportModalProps): VNode | null {
)}
</div>

{error ? (
<div role="alert" style="color:#b91c1c;background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:8px 12px;font-size:13px">
{error}
</div>
) : null}

<div style="display:flex;gap:8px;justify-content:flex-end;padding-top:8px;border-top:1px solid var(--wl-line-2)">
<button class="wl-btn" onClick={onClose}>Cancel</button>
<button
Expand Down
13 changes: 13 additions & 0 deletions src/ui/screens/ExportScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,13 @@ export function ExportScreen(props: {
const [tabNameDraft, setTabNameDraft] = useState('');
const [workspaceLoaded, setWorkspaceLoaded] = useState(false);
const workspaceKey = `exportWorkspace:${context?.orgId ?? 'local'}`;
// Track which workspaceKey the current tabs belong to, so the save effect
// never writes stale tabs under a new org's key during the async load gap.
const loadedForKeyRef = useRef<string | null>(null);

useEffect(() => {
setWorkspaceLoaded(false);
loadedForKeyRef.current = null;
chrome.storage.local.get([workspaceKey], result => {
const saved = result[workspaceKey] as { tabs?: QueryWorkspaceTab[]; activeTabId?: string } | undefined;
if (saved?.tabs?.length) {
Expand All @@ -71,13 +75,22 @@ export function ExportScreen(props: {
setActiveTabId(nextActive);
onSoqlChange(restoredTabs.find(tab => tab.id === nextActive)?.soql ?? soql);
nextTabNumber.current = restoredTabs.length + 1;
} else {
// New org with no saved workspace — reset to defaults so stale tabs
// from the previous org are not displayed or persisted.
setTabs([normalizeTab({ id: 'query-1', name: 'Query 1', soql })]);
setActiveTabId('query-1');
onSoqlChange(soql);
nextTabNumber.current = 2;
}
loadedForKeyRef.current = workspaceKey;
setWorkspaceLoaded(true);
});
}, [workspaceKey]);

useEffect(() => {
if (!workspaceLoaded) return;
if (loadedForKeyRef.current !== workspaceKey) return;
chrome.storage.local.set({ [workspaceKey]: { tabs, activeTabId } });
}, [workspaceLoaded, workspaceKey, tabs, activeTabId]);

Expand Down
3 changes: 2 additions & 1 deletion src/ui/screens/QueryScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,8 @@ export function QueryScreen(props: {
<ExportModal
open={exportOpen}
records={flatRecords}
columns={selectedColumns.length ? selectedColumns : columns}
columns={columns}
selectedColumns={selectedColumns.length ? selectedColumns : columns}
defaultFilename={`wavelink-query-${Date.now()}`}
preferences={props.exportPreferences}
onPreferencesChange={props.onExportPreferencesChange}
Expand Down
4 changes: 3 additions & 1 deletion src/ui/utils/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export function downloadTextFile(filename: string, content: string, contentType:

/** Build a download from bounded generated chunks instead of one giant string. */
export function downloadBlobParts(filename: string, parts: BlobPart[], contentType: string): void {
const blob = new Blob(parts, { type: contentType });
// Prepend UTF-8 BOM for CSV so Windows Excel opens it correctly (issue #72)
const finalParts: BlobPart[] = contentType === 'text/csv' ? ['', ...parts] : parts;
const blob = new Blob(finalParts, { type: contentType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
Expand Down
6 changes: 3 additions & 3 deletions src/ui/utils/records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export function flattenRecord(
const out: FlatRecord = {};

function visit(value: unknown, path: string, depth: number): void {
if (path === 'attributes') return;
if (value === undefined) return;

if (value === null) {
Expand All @@ -48,6 +47,7 @@ export function flattenRecord(
return;
}
for (const [k, v] of Object.entries(value)) {
if (k === 'attributes') continue;
const nextPath = path ? `${path}.${k}` : k;
visit(v, nextPath, depth + 1);
}
Expand All @@ -66,9 +66,9 @@ export function flattenRecord(
return cleaned;
}

export function deriveColumns(records: Array<Record<string, unknown>>, limit: number = 50): string[] {
export function deriveColumns(records: Array<Record<string, unknown>>): string[] {
const cols = new Set<string>();
for (const rec of records.slice(0, limit)) {
for (const rec of records) {
const flat = flattenRecord(rec);
for (const key of Object.keys(flat)) {
cols.add(key);
Expand Down
6 changes: 3 additions & 3 deletions tests/unit/queryAll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@ describe('deriveColumns', () => {
expect(deriveColumns(records)).toEqual(['Id', 'Name', 'Industry']);
});

it('only samples up to sampleSize records', () => {
it('derives columns from all records, not just a sample', () => {
const records = [
{ Id: '1' },
{ Id: '2', Late: 'x' }, // beyond sampleSize=1, so Late not picked up
{ Id: '2', Late: 'x' },
];
expect(deriveColumns(records, 1)).toEqual(['Id']);
expect(deriveColumns(records)).toEqual(['Id', 'Late']);
});
});
Loading