Skip to content

Commit ea163e1

Browse files
authored
Merge pull request #35 from kshashikumar/export_feature
Export feature
2 parents a129b0e + e09912f commit ea163e1

2 files changed

Lines changed: 257 additions & 0 deletions

File tree

client/dbfuse-ai-client/src/app/pages/resultgrid/resultgrid.component.html

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@
105105
</div>
106106
</div>
107107
</div>
108+
<button
109+
(click)="openExportModal()"
110+
title="Export results"
111+
class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-blue-500 dark:hover:bg-blue-600"
112+
>
113+
<span class="icon-[mdi--download] w-3 h-3 inline-block mr-1"></span>Export
114+
</button>
108115
</div>
109116
</div>
110117
</div>
@@ -329,3 +336,38 @@ <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">Ready to Exec
329336
</div>
330337
</div>
331338
</div>
339+
340+
<!-- Export Modal -->
341+
<div *ngIf="showExportModal" class="fixed inset-0 z-50 flex items-center justify-center">
342+
<div class="absolute inset-0 bg-black/40" (click)="closeExportModal()" aria-hidden="true"></div>
343+
344+
<div
345+
role="dialog"
346+
aria-modal="true"
347+
class="relative bg-white dark:bg-gray-800 rounded-lg shadow-lg w-96 p-4 z-10"
348+
>
349+
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Export Results</h3>
350+
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">Choose scope and format for export.</p>
351+
352+
<div class="mb-3">
353+
<label class="flex items-center space-x-2">
354+
<input type="radio" name="exportScope" value="current" [(ngModel)]="exportScope" />
355+
<span class="text-sm text-gray-700 dark:text-gray-200">Current page</span>
356+
</label>
357+
<label class="flex items-center space-x-2 mt-2">
358+
<input type="radio" name="exportScope" value="all" [(ngModel)]="exportScope" />
359+
<span class="text-sm text-gray-700 dark:text-gray-200">Full results (if available)</span>
360+
</label>
361+
</div>
362+
363+
<div class="flex items-center justify-between space-x-2">
364+
<button class="flex-1 px-3 py-2 text-xs rounded bg-blue-600 text-white" (click)="confirmExport('json')">Export JSON</button>
365+
<button class="flex-1 px-3 py-2 text-xs rounded bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600" (click)="confirmExport('csv')">Export CSV</button>
366+
<button class="flex-1 px-3 py-2 text-xs rounded bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600" (click)="confirmExport('excel')">Export Excel</button>
367+
</div>
368+
369+
<div class="mt-3 text-right">
370+
<button class="text-xs text-gray-500 dark:text-gray-400" (click)="closeExportModal()">Cancel</button>
371+
</div>
372+
</div>
373+
</div>

client/dbfuse-ai-client/src/app/pages/resultgrid/resultgrid.component.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { FormsModule } from '@angular/forms';
1414
import { RouterModule } from '@angular/router';
1515
import { BackendService } from '@lib/services';
1616
import { TruncatePipe } from '@lib/providers/truncate.pipe';
17+
import { firstValueFrom } from 'rxjs';
1718

1819
@Component({
1920
selector: 'app-resultgrid',
@@ -48,6 +49,9 @@ export class ResultGridComponent implements OnInit {
4849

4950
queryResults: any[] = []; // data.queries[]
5051
activeQueryIndex: number = 0;
52+
showExportModal: boolean = false;
53+
exportScope: 'current' | 'all' = 'current';
54+
exportFormat: 'csv' | 'json' = 'csv';
5155

5256
// Convenience getter for template access
5357
get activeResult(): any | null {
@@ -362,4 +366,215 @@ export class ResultGridComponent implements OnInit {
362366
}
363367
return 'string';
364368
}
369+
370+
openExportModal(): void {
371+
this.exportScope = 'current';
372+
this.showExportModal = true;
373+
this._cdr.markForCheck();
374+
}
375+
376+
closeExportModal(): void {
377+
this.showExportModal = false;
378+
this._cdr.markForCheck();
379+
}
380+
381+
async confirmExport(format: 'json' | 'csv' | 'excel'): Promise<void> {
382+
let data: any[] = [];
383+
384+
if (this.exportScope === 'all') {
385+
// prefer already-available full payload
386+
const active = this.activeResult;
387+
if (active?.allRows && Array.isArray(active.allRows) && active.allRows.length) {
388+
data = active.allRows;
389+
} else {
390+
// try to fetch all pages from backend
391+
data = await this.fetchAllRows();
392+
}
393+
} else {
394+
data = this.rows || [];
395+
}
396+
397+
if (!data || data.length === 0) {
398+
window.alert('No rows available to export.');
399+
this.closeExportModal();
400+
return;
401+
}
402+
403+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
404+
const scope = this.exportScope === 'all' ? 'all' : 'current';
405+
const baseName = `result-${scope}-${timestamp}`;
406+
407+
if (format === 'json') {
408+
const blob = new Blob([this.convertToJSON(data)], { type: 'application/json;charset=utf-8' });
409+
this.downloadFile(`${baseName}.json`, blob);
410+
} else if (format === 'csv') {
411+
const csv = this.convertToCSV(data);
412+
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
413+
this.downloadFile(`${baseName}.csv`, blob);
414+
} else if (format === 'excel') {
415+
const html = this.convertToExcel(data);
416+
const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' });
417+
this.downloadFile(`${baseName}.xls`, blob);
418+
}
419+
420+
this.closeExportModal();
421+
}
422+
423+
private getDataForExport(): any[] {
424+
// export current grid rows for "page"
425+
if (this.exportScope === 'current') {
426+
return this.rows || [];
427+
}
428+
429+
// export "full" - try to use any available full result payload
430+
const active = this.queryResults[this.activeQueryIndex];
431+
// If server provided full data field (custom), respect it
432+
if (active?.allRows && Array.isArray(active.allRows) && active.allRows.length > 0) {
433+
return active.allRows;
434+
}
435+
436+
// If pagination indicates more results on server, inform user and fallback to current page
437+
if (active?.pagination?.hasMore) {
438+
// keep short / impersonal
439+
window.alert('Full result set is not fully loaded; exporting current page only.');
440+
return this.rows || [];
441+
}
442+
443+
// fallback to whatever rows are present
444+
return active?.rows || this.rows || [];
445+
}
446+
447+
private convertToJSON(data: any[]): string {
448+
return JSON.stringify(data, null, 2);
449+
}
450+
451+
private convertToCSV(data: any[]): string {
452+
if (!data || data.length === 0) return '';
453+
454+
const cols = this.headers && this.headers.length > 0 ? this.headers : Object.keys(data[0] || {});
455+
const escape = (v: any) => {
456+
if (v === null || v === undefined) return '';
457+
const s = String(v);
458+
// escape double quotes
459+
if (s.includes('"') || s.includes(',') || s.includes('\n') || s.includes('\r')) {
460+
return `"${s.replace(/"/g, '""')}"`;
461+
}
462+
return s;
463+
};
464+
465+
const lines = [];
466+
lines.push(cols.join(','));
467+
for (const row of data) {
468+
const vals = cols.map((c) => escape(row[c]));
469+
lines.push(vals.join(','));
470+
}
471+
return lines.join('\r\n');
472+
}
473+
474+
private convertToExcel(data: any[]): string {
475+
// Build a minimal HTML table which Excel can open
476+
const cols = this.headers && this.headers.length > 0 ? this.headers : Object.keys(data[0] || {});
477+
const headerRow = cols.map((c) => `<th style="border:1px solid #ccc;padding:4px;background:#f0f0f0;">${this.escapeHtml(c)}</th>`).join('');
478+
const bodyRows = data
479+
.map((row) => {
480+
const cells = cols
481+
.map((c) => `<td style="border:1px solid #ccc;padding:4px;">${this.escapeHtml(row[c])}</td>`)
482+
.join('');
483+
return `<tr>${cells}</tr>`;
484+
})
485+
.join('');
486+
return `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><table border="0" cellpadding="0" cellspacing="0">${'<thead><tr>' + headerRow + '</tr></thead>'}<tbody>${bodyRows}</tbody></table></body></html>`;
487+
}
488+
489+
private escapeHtml(value: any): string {
490+
if (value === null || value === undefined) return '';
491+
return String(value)
492+
.replace(/&/g, '&amp;')
493+
.replace(/</g, '&lt;')
494+
.replace(/>/g, '&gt;')
495+
.replace(/"/g, '&quot;');
496+
}
497+
498+
private downloadFile(filename: string, blob: Blob): void {
499+
const url = URL.createObjectURL(blob);
500+
const a = document.createElement('a');
501+
a.href = url;
502+
a.download = filename;
503+
document.body.appendChild(a);
504+
a.click();
505+
a.remove();
506+
URL.revokeObjectURL(url);
507+
}
508+
509+
private async fetchAllRows(): Promise<any[]> {
510+
const active = this.activeResult;
511+
// choose base SQL: prefer the active statement, else fall back to original triggerQuery
512+
const query = active?.query || this.triggerQuery;
513+
const pageSize = active?.pagination?.pageSize || this.pageSize;
514+
const totalPagesFromMeta = active?.pagination?.totalPages;
515+
const totalRowsFromMeta = active?.totalRows || this.totalRows;
516+
517+
// compute pages to fetch
518+
let totalPages = 1;
519+
if (typeof totalPagesFromMeta === 'number' && totalPagesFromMeta > 0) {
520+
totalPages = totalPagesFromMeta;
521+
} else if (totalRowsFromMeta && pageSize) {
522+
totalPages = Math.ceil(totalRowsFromMeta / pageSize);
523+
}
524+
525+
// safety cap
526+
const MAX_PAGES = 1000;
527+
totalPages = Math.min(totalPages || 1, MAX_PAGES);
528+
529+
const allRows: any[] = [];
530+
531+
for (let p = 1; p <= totalPages; p++) {
532+
try {
533+
const resp: any = await firstValueFrom(
534+
this._dbService.executeQuery(query, this.dbName, { page: p, pageSize }),
535+
);
536+
537+
let pageRows: any[] = [];
538+
539+
if (!resp) {
540+
break;
541+
}
542+
543+
// handle multi-query response vs single-query
544+
if (Array.isArray(resp.queries)) {
545+
// try to find matching statement by text, fallback to first
546+
const found = resp.queries.find((q: any) => q.query === query) || resp.queries[0];
547+
pageRows = Array.isArray(found?.rows) ? found.rows : [];
548+
} else {
549+
pageRows = Array.isArray(resp.rows) ? resp.rows : [];
550+
}
551+
552+
if (pageRows.length > 0) {
553+
allRows.push(...pageRows);
554+
}
555+
556+
// if server indicates no more pages, break early
557+
const pageMeta =
558+
Array.isArray(resp.queries)
559+
? (resp.queries.find((q: any) => q.query === query) || resp.queries[0])?.pagination
560+
: resp?.pagination;
561+
if (pageMeta && pageMeta.hasMore === false) {
562+
break;
563+
}
564+
565+
// If server returned fewer rows than pageSize, assume last page
566+
if (pageRows.length < pageSize) {
567+
break;
568+
}
569+
} catch (err) {
570+
// stop on error and return what we have
571+
console.error('Error fetching page', p, err);
572+
break;
573+
}
574+
}
575+
576+
return allRows;
577+
}
578+
579+
365580
}

0 commit comments

Comments
 (0)