Skip to content

Commit 187116a

Browse files
Copilothotlong
andcommitted
feat(plugin-report): add live data export, Excel formulas, and schedule triggers
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 61fedf9 commit 187116a

3 files changed

Lines changed: 603 additions & 0 deletions

File tree

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Live Report Exporter
11+
*
12+
* Higher-level export engine that integrates with DataSource adapters
13+
* to fetch live data before exporting to PDF, Excel, or other formats.
14+
* Also provides Excel formula support and scheduled report generation triggers.
15+
*
16+
* @module plugin-report
17+
* @packageDocumentation
18+
*/
19+
20+
import type {
21+
DataSource,
22+
QueryParams,
23+
ReportSchema,
24+
ReportExportFormat,
25+
ReportExportConfig,
26+
ReportField,
27+
ReportSchedule,
28+
} from '@object-ui/types';
29+
import { exportReport } from './ReportExportEngine';
30+
31+
/**
32+
* Options for live report export
33+
*/
34+
export interface LiveExportOptions {
35+
/** Data source adapter (e.g. ApiDataSource or ObjectStackAdapter) */
36+
dataSource: DataSource;
37+
/** Resource / object name to query */
38+
resource: string;
39+
/** Additional query parameters */
40+
queryParams?: QueryParams;
41+
/** Export format override */
42+
format?: ReportExportFormat;
43+
/** Export config override */
44+
exportConfig?: ReportExportConfig;
45+
}
46+
47+
/**
48+
* Excel column configuration for formatted export
49+
*/
50+
export interface ExcelColumnConfig {
51+
/** Field name */
52+
name: string;
53+
/** Column display header */
54+
header: string;
55+
/** Column width (character units) */
56+
width?: number;
57+
/** Number format (e.g. '#,##0.00', '0%') */
58+
numberFormat?: string;
59+
/** Excel formula template (use {ROW} as row placeholder) */
60+
formula?: string;
61+
}
62+
63+
/**
64+
* Result of a live export operation
65+
*/
66+
export interface LiveExportResult {
67+
/** Whether the export succeeded */
68+
success: boolean;
69+
/** Number of records exported */
70+
recordCount: number;
71+
/** Export format used */
72+
format: ReportExportFormat;
73+
/** Error message if failed */
74+
error?: string;
75+
}
76+
77+
/**
78+
* Schedule trigger callback
79+
*/
80+
export type ScheduleTriggerCallback = (
81+
report: ReportSchema,
82+
schedule: ReportSchedule,
83+
) => void;
84+
85+
/**
86+
* Export a report with live data fetched from a DataSource adapter.
87+
*
88+
* @example
89+
* ```ts
90+
* await exportWithLiveData(report, {
91+
* dataSource: myAdapter,
92+
* resource: 'orders',
93+
* format: 'pdf',
94+
* });
95+
* ```
96+
*/
97+
export async function exportWithLiveData(
98+
report: ReportSchema,
99+
options: LiveExportOptions,
100+
): Promise<LiveExportResult> {
101+
const {
102+
dataSource,
103+
resource,
104+
queryParams,
105+
format,
106+
exportConfig,
107+
} = options;
108+
109+
const exportFormat = format || report.defaultExportFormat || 'pdf';
110+
111+
try {
112+
// Fetch live data from adapter
113+
const result = await dataSource.find(resource, queryParams);
114+
const data = result.data || [];
115+
116+
// Merge export config from report schema and options
117+
const mergedConfig: ReportExportConfig = {
118+
format: exportFormat,
119+
...report.exportConfigs?.[exportFormat],
120+
...exportConfig,
121+
};
122+
123+
// Route to export handler
124+
exportReport(exportFormat, report, data, mergedConfig);
125+
126+
return {
127+
success: true,
128+
recordCount: data.length,
129+
format: exportFormat,
130+
};
131+
} catch (error) {
132+
return {
133+
success: false,
134+
recordCount: 0,
135+
format: exportFormat,
136+
error: (error as Error).message,
137+
};
138+
}
139+
}
140+
141+
/**
142+
* Export report as Excel with formatted columns and formula support.
143+
*
144+
* Generates a TSV file with:
145+
* - Column headers from field labels
146+
* - Formatted numeric values
147+
* - Excel formula cells (=SUM, =AVERAGE, etc.)
148+
* - UTF-8 BOM for proper character display
149+
*
150+
* @example
151+
* ```ts
152+
* exportExcelWithFormulas(report, data, {
153+
* columns: [
154+
* { name: 'amount', header: 'Amount', numberFormat: '#,##0.00' },
155+
* { name: 'total', header: 'Total', formula: '=B{ROW}*C{ROW}' },
156+
* ],
157+
* includeAggregationRow: true,
158+
* });
159+
* ```
160+
*/
161+
export function exportExcelWithFormulas(
162+
report: ReportSchema,
163+
data: any[],
164+
options: {
165+
columns?: ExcelColumnConfig[];
166+
filename?: string;
167+
includeAggregationRow?: boolean;
168+
} = {},
169+
): void {
170+
const { columns, filename, includeAggregationRow = false } = options;
171+
172+
// Determine columns from options or fall back to report fields
173+
const cols: ExcelColumnConfig[] = columns || (report.fields || []).map(fieldToExcelColumn);
174+
175+
// Build header row
176+
const headers = cols.map(c => c.header);
177+
178+
// Build data rows
179+
const rows: string[][] = data.map((row, rowIndex) => {
180+
return cols.map(col => {
181+
if (col.formula) {
182+
// Excel formula: replace {ROW} with actual row number (header is row 1)
183+
return col.formula.replace(/\{ROW\}/g, String(rowIndex + 2));
184+
}
185+
const value = row[col.name];
186+
return formatCellValue(value, col.numberFormat);
187+
});
188+
});
189+
190+
// Optional aggregation row
191+
if (includeAggregationRow && data.length > 0) {
192+
const aggRow = cols.map((col, colIndex) => {
193+
const field = (report.fields || []).find(f => f.name === col.name);
194+
if (field?.aggregation) {
195+
const colLetter = getExcelColumnLetter(colIndex);
196+
const dataStart = 2; // Row 2 (after header)
197+
const dataEnd = data.length + 1;
198+
switch (field.aggregation) {
199+
case 'sum':
200+
return `=SUM(${colLetter}${dataStart}:${colLetter}${dataEnd})`;
201+
case 'avg':
202+
return `=AVERAGE(${colLetter}${dataStart}:${colLetter}${dataEnd})`;
203+
case 'count':
204+
return `=COUNTA(${colLetter}${dataStart}:${colLetter}${dataEnd})`;
205+
case 'min':
206+
return `=MIN(${colLetter}${dataStart}:${colLetter}${dataEnd})`;
207+
case 'max':
208+
return `=MAX(${colLetter}${dataStart}:${colLetter}${dataEnd})`;
209+
default:
210+
return '';
211+
}
212+
}
213+
return '';
214+
});
215+
rows.push(aggRow);
216+
}
217+
218+
// Build TSV content with BOM
219+
const tsvContent = '\uFEFF' + [
220+
headers.join('\t'),
221+
...rows.map(r => r.join('\t')),
222+
].join('\n');
223+
224+
const outputFilename = filename || `${report.title || 'report'}.tsv`;
225+
downloadFile(tsvContent, outputFilename, 'text/tab-separated-values');
226+
}
227+
228+
/**
229+
* Create a schedule trigger that can be invoked by workflow engines.
230+
*
231+
* Returns a callback that, when invoked, exports the report using the
232+
* schedule's configured formats and notifies the provided handler.
233+
*
234+
* @example
235+
* ```ts
236+
* const trigger = createScheduleTrigger(report, dataSource, 'orders', (report, schedule) => {
237+
* // Send email with attachments
238+
* sendEmail(schedule.recipients, schedule.subject, ...);
239+
* });
240+
*
241+
* // Invoke from workflow engine
242+
* await trigger();
243+
* ```
244+
*/
245+
export function createScheduleTrigger(
246+
report: ReportSchema,
247+
dataSource: DataSource,
248+
resource: string,
249+
onComplete: ScheduleTriggerCallback,
250+
): () => Promise<LiveExportResult[]> {
251+
return async () => {
252+
const schedule = report.schedule;
253+
if (!schedule?.enabled) {
254+
return [];
255+
}
256+
257+
const formats = schedule.formats || [report.defaultExportFormat || 'pdf'];
258+
const results: LiveExportResult[] = [];
259+
260+
for (const format of formats) {
261+
const result = await exportWithLiveData(report, {
262+
dataSource,
263+
resource,
264+
format,
265+
});
266+
results.push(result);
267+
}
268+
269+
onComplete(report, schedule);
270+
return results;
271+
};
272+
}
273+
274+
// ==========================================================================
275+
// Helpers
276+
// ==========================================================================
277+
278+
/**
279+
* Convert a ReportField to an ExcelColumnConfig
280+
*/
281+
function fieldToExcelColumn(field: ReportField): ExcelColumnConfig {
282+
return {
283+
name: field.name,
284+
header: field.label || field.name,
285+
};
286+
}
287+
288+
/**
289+
* Format a cell value for Excel export
290+
*/
291+
function formatCellValue(value: any, numberFormat?: string): string {
292+
if (value == null) return '';
293+
if (typeof value === 'number' && numberFormat) {
294+
// Basic formatting: apply locale-aware number formatting
295+
return value.toLocaleString('en-US', inferLocaleOptions(numberFormat));
296+
}
297+
return sanitizeExcelValue(String(value));
298+
}
299+
300+
/**
301+
* Infer Intl.NumberFormat options from a simple format string
302+
*/
303+
function inferLocaleOptions(format: string): Intl.NumberFormatOptions {
304+
if (format.includes('%')) {
305+
return { style: 'percent', minimumFractionDigits: 0 };
306+
}
307+
const decimals = (format.match(/0/g) || []).length;
308+
return {
309+
minimumFractionDigits: Math.max(0, decimals - 1),
310+
maximumFractionDigits: Math.max(0, decimals - 1),
311+
};
312+
}
313+
314+
/**
315+
* Convert column index (0-based) to Excel column letter (A, B, ..., Z, AA, AB, ...)
316+
*/
317+
function getExcelColumnLetter(index: number): string {
318+
let letter = '';
319+
let n = index;
320+
while (n >= 0) {
321+
letter = String.fromCharCode((n % 26) + 65) + letter;
322+
n = Math.floor(n / 26) - 1;
323+
}
324+
return letter;
325+
}
326+
327+
/**
328+
* Sanitize cell values to prevent formula injection in Excel.
329+
* Prefixes values starting with formula characters (=, +, -, @, |) with a tab.
330+
*/
331+
function sanitizeExcelValue(val: string): string {
332+
if (val.length > 0) {
333+
const firstChar = val.charAt(0);
334+
if (firstChar === '=' || firstChar === '+' || firstChar === '-' || firstChar === '@' || firstChar === '|') {
335+
return `\t${val}`;
336+
}
337+
}
338+
return val;
339+
}
340+
341+
/**
342+
* Trigger file download in browser
343+
*/
344+
function downloadFile(content: string, filename: string, mimeType: string): void {
345+
const blob = new Blob([content], { type: `${mimeType};charset=utf-8` });
346+
const url = URL.createObjectURL(blob);
347+
const link = document.createElement('a');
348+
link.href = url;
349+
link.download = filename;
350+
document.body.appendChild(link);
351+
link.click();
352+
document.body.removeChild(link);
353+
URL.revokeObjectURL(url);
354+
}

0 commit comments

Comments
 (0)