@opencorestack/opengridx 1.1.0 → 1.2.0
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/CHANGELOG.md +12 -0
- package/dist/index.d.ts +60 -0
- package/dist/opengridx.es.js +2111 -1995
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +11 -11
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +50 -0
- package/docs/features/pdf-export.md +110 -0
- package/lib/index.ts +3 -1
- package/lib/types/index.ts +69 -0
- package/lib/utils/export/exportToPdf.test.ts +121 -0
- package/lib/utils/export/exportToPdf.ts +277 -0
- package/lib/utils/export/index.ts +3 -0
- package/lib/utils/export/pdf-types.d.ts +19 -0
- package/package.json +9 -1
package/docs/API_REFERENCE.md
CHANGED
|
@@ -1218,3 +1218,53 @@ export default function MobileGrid() {
|
|
|
1218
1218
|
);
|
|
1219
1219
|
}
|
|
1220
1220
|
```
|
|
1221
|
+
|
|
1222
|
+
---
|
|
1223
|
+
|
|
1224
|
+
## 🔧 Utilities
|
|
1225
|
+
|
|
1226
|
+
### `exportToPdf`
|
|
1227
|
+
|
|
1228
|
+
```ts
|
|
1229
|
+
function exportToPdf<R extends GridRowModel>(
|
|
1230
|
+
rows: R[],
|
|
1231
|
+
columns: GridColDef<R>[],
|
|
1232
|
+
options?: PdfExportOptions
|
|
1233
|
+
): Promise<void>
|
|
1234
|
+
```
|
|
1235
|
+
|
|
1236
|
+
Generates a styled PDF report and triggers a browser download. Requires peer deps `jspdf` and `jspdf-autotable`. See [docs/features/pdf-export.md](./features/pdf-export.md) for full usage.
|
|
1237
|
+
|
|
1238
|
+
### `PdfExportOptions`
|
|
1239
|
+
|
|
1240
|
+
```ts
|
|
1241
|
+
interface PdfExportOptions {
|
|
1242
|
+
fileName?: string;
|
|
1243
|
+
title?: string;
|
|
1244
|
+
logoUrl?: string;
|
|
1245
|
+
orientation?: 'portrait' | 'landscape';
|
|
1246
|
+
selectedRows?: (string | number)[];
|
|
1247
|
+
aggregationResult?: Record<string, unknown> | null;
|
|
1248
|
+
aggregationModel?: GridAggregationModel | null;
|
|
1249
|
+
filterModel?: GridFilterModel | null;
|
|
1250
|
+
alternateRowColor?: boolean;
|
|
1251
|
+
headerBackgroundColor?: string;
|
|
1252
|
+
headerTextColor?: string;
|
|
1253
|
+
fontSize?: number;
|
|
1254
|
+
}
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
| Property | Type | Default | Description |
|
|
1258
|
+
|:---|:---|:---|:---|
|
|
1259
|
+
| `fileName` | `string` | `'export'` | Output filename without `.pdf` extension |
|
|
1260
|
+
| `title` | `string` | — | Adds a branded header block above the table |
|
|
1261
|
+
| `logoUrl` | `string` | — | Data URI or URL for a logo image (requires `title`) |
|
|
1262
|
+
| `orientation` | `'portrait' \| 'landscape'` | `'landscape'` | Page orientation |
|
|
1263
|
+
| `selectedRows` | `(string \| number)[]` | — | Export only rows with these IDs |
|
|
1264
|
+
| `aggregationResult` | `Record<string, unknown>` | — | From `apiRef.current.getAggregationResult()` |
|
|
1265
|
+
| `aggregationModel` | `GridAggregationModel` | — | From `apiRef.current.getAggregationModel()` |
|
|
1266
|
+
| `filterModel` | `GridFilterModel` | — | From `apiRef.current.getFilterModel()` |
|
|
1267
|
+
| `alternateRowColor` | `boolean` | `true` | Alternating row background shading |
|
|
1268
|
+
| `headerBackgroundColor` | `string` | `'#4f46e5'` | Column header cell background (hex) |
|
|
1269
|
+
| `headerTextColor` | `string` | `'#ffffff'` | Column header cell text color (hex) |
|
|
1270
|
+
| `fontSize` | `number` | `9` | Body cell font size in points |
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# PDF Export
|
|
2
|
+
|
|
3
|
+
Generate a styled PDF report from grid data using `exportToPdf()`.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
`exportToPdf` requires two optional peer dependencies:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install jspdf jspdf-autotable
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Basic Usage
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { exportToPdf, useGridApiRef } from '@opencorestack/opengridx';
|
|
17
|
+
|
|
18
|
+
function MyGrid() {
|
|
19
|
+
const apiRef = useGridApiRef();
|
|
20
|
+
|
|
21
|
+
const handleExport = async () => {
|
|
22
|
+
await exportToPdf(
|
|
23
|
+
apiRef.current.getVisibleRows(),
|
|
24
|
+
apiRef.current.getVisibleColumns(),
|
|
25
|
+
{ fileName: 'my-report', title: 'Sales Report' }
|
|
26
|
+
);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<DataGrid
|
|
31
|
+
apiRef={apiRef}
|
|
32
|
+
rows={rows}
|
|
33
|
+
columns={columns}
|
|
34
|
+
slots={{
|
|
35
|
+
toolbar: () => (
|
|
36
|
+
<GridToolbar
|
|
37
|
+
renderExportButton={() => (
|
|
38
|
+
<button onClick={handleExport}>Export PDF</button>
|
|
39
|
+
)}
|
|
40
|
+
/>
|
|
41
|
+
),
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Full Example with All Options
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
await exportToPdf(
|
|
52
|
+
apiRef.current.getVisibleRows(),
|
|
53
|
+
apiRef.current.getVisibleColumns(),
|
|
54
|
+
{
|
|
55
|
+
fileName: 'employee-report',
|
|
56
|
+
title: 'Employee Report',
|
|
57
|
+
logoUrl: '/logo.png', // data URI or URL
|
|
58
|
+
orientation: 'landscape', // 'portrait' | 'landscape'
|
|
59
|
+
aggregationResult: apiRef.current.getAggregationResult(),
|
|
60
|
+
aggregationModel: apiRef.current.getAggregationModel(),
|
|
61
|
+
filterModel: apiRef.current.getFilterModel(),
|
|
62
|
+
selectedRows: apiRef.current.getSelectedRows(), // already GridRowId[]
|
|
63
|
+
alternateRowColor: true,
|
|
64
|
+
headerBackgroundColor: '#1e40af',
|
|
65
|
+
headerTextColor: '#ffffff',
|
|
66
|
+
fontSize: 9,
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## `PdfExportOptions` Reference
|
|
72
|
+
|
|
73
|
+
| Property | Type | Default | Description |
|
|
74
|
+
|:---|:---|:---|:---|
|
|
75
|
+
| `fileName` | `string` | `'export'` | Output filename without `.pdf` extension |
|
|
76
|
+
| `title` | `string` | — | Adds a branded header block above the table |
|
|
77
|
+
| `logoUrl` | `string` | — | Data URI or URL for a logo image (requires `title`) |
|
|
78
|
+
| `orientation` | `'portrait' \| 'landscape'` | `'landscape'` | Page orientation |
|
|
79
|
+
| `selectedRows` | `(string \| number)[]` | — | Export only rows with these IDs |
|
|
80
|
+
| `aggregationResult` | `Record<string, unknown>` | — | From `apiRef.current.getAggregationResult()` |
|
|
81
|
+
| `aggregationModel` | `GridAggregationModel` | — | From `apiRef.current.getAggregationModel()` |
|
|
82
|
+
| `filterModel` | `GridFilterModel` | — | From `apiRef.current.getFilterModel()` |
|
|
83
|
+
| `alternateRowColor` | `boolean` | `true` | Alternating row background shading |
|
|
84
|
+
| `headerBackgroundColor` | `string` | `'#4f46e5'` | Column header cell background (hex) |
|
|
85
|
+
| `headerTextColor` | `string` | `'#ffffff'` | Column header cell text color (hex) |
|
|
86
|
+
| `fontSize` | `number` | `9` | Body cell font size in points |
|
|
87
|
+
|
|
88
|
+
## Column Exclusion
|
|
89
|
+
|
|
90
|
+
Columns with `exportable: false` are excluded automatically:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
{ field: 'internalNotes', headerName: 'Notes', exportable: false }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
System columns (`__check__`, `__actions__`) are also always excluded.
|
|
97
|
+
|
|
98
|
+
## Troubleshooting
|
|
99
|
+
|
|
100
|
+
**Error: "exportToPdf requires 'jspdf' and 'jspdf-autotable'"**
|
|
101
|
+
|
|
102
|
+
Run: `npm install jspdf jspdf-autotable`
|
|
103
|
+
|
|
104
|
+
**Logo not appearing**
|
|
105
|
+
|
|
106
|
+
Ensure `logoUrl` is a data URI (base64-encoded) or an absolute URL that is CORS-accessible. Relative paths may fail in some environments.
|
|
107
|
+
|
|
108
|
+
## Exporting unfiltered data
|
|
109
|
+
|
|
110
|
+
`getVisibleRows()` returns only rows that pass the current filter. Use `getAllRows()` if you want to export all data regardless of active filters.
|
package/lib/index.ts
CHANGED
|
@@ -19,7 +19,8 @@ export {
|
|
|
19
19
|
exportToCsv,
|
|
20
20
|
exportToExcel,
|
|
21
21
|
exportToJson,
|
|
22
|
-
printGrid
|
|
22
|
+
printGrid,
|
|
23
|
+
exportToPdf
|
|
23
24
|
} from './utils/export/index';
|
|
24
25
|
|
|
25
26
|
export { exportToExcelAdvanced } from './utils/export/exportToExcelAdvanced';
|
|
@@ -105,4 +106,5 @@ export type {
|
|
|
105
106
|
GridDetailPanelParams,
|
|
106
107
|
GridRowMeta,
|
|
107
108
|
GridLocaleText,
|
|
109
|
+
PdfExportOptions,
|
|
108
110
|
} from './types';
|
package/lib/types/index.ts
CHANGED
|
@@ -309,6 +309,75 @@ export interface GridFilterModel {
|
|
|
309
309
|
quickFilterValues?: string[];
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Configuration options for exporting the DataGrid to PDF format.
|
|
314
|
+
* Requires jspdf and jspdf-autotable peer dependencies.
|
|
315
|
+
*/
|
|
316
|
+
export interface PdfExportOptions {
|
|
317
|
+
/**
|
|
318
|
+
* Output filename without extension. Default: 'export'
|
|
319
|
+
*/
|
|
320
|
+
fileName?: string;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Title text displayed in the document header.
|
|
324
|
+
* When omitted, no header block is rendered.
|
|
325
|
+
*/
|
|
326
|
+
title?: string;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* URL or data URI of a logo image rendered left of the title.
|
|
330
|
+
* Only used when `title` is also set. Default: none
|
|
331
|
+
*/
|
|
332
|
+
logoUrl?: string;
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Page orientation. Default: 'landscape'
|
|
336
|
+
*/
|
|
337
|
+
orientation?: 'portrait' | 'landscape';
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* If provided, only rows whose id is in this array are exported.
|
|
341
|
+
*/
|
|
342
|
+
selectedRows?: (string | number)[];
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Aggregation result from apiRef.current.getAggregationResult().
|
|
346
|
+
* When provided alongside aggregationModel, appends a footer row.
|
|
347
|
+
*/
|
|
348
|
+
aggregationResult?: Record<string, unknown> | null;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Aggregation model from apiRef.current.getAggregationModel().
|
|
352
|
+
*/
|
|
353
|
+
aggregationModel?: GridAggregationModel | null;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Active filter model. When provided, a filter summary is shown in the header.
|
|
357
|
+
*/
|
|
358
|
+
filterModel?: GridFilterModel | null;
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Whether to apply alternating row shading. Default: true
|
|
362
|
+
*/
|
|
363
|
+
alternateRowColor?: boolean;
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Column header background color (hex). Default: '#4f46e5'
|
|
367
|
+
*/
|
|
368
|
+
headerBackgroundColor?: string;
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Column header text color (hex). Default: '#ffffff'
|
|
372
|
+
*/
|
|
373
|
+
headerTextColor?: string;
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Base font size in points for table body. Default: 9
|
|
377
|
+
*/
|
|
378
|
+
fontSize?: number;
|
|
379
|
+
}
|
|
380
|
+
|
|
312
381
|
export type GridPinnedPosition = 'left' | 'right';
|
|
313
382
|
|
|
314
383
|
export type GridColumnVisibilityModel = Record<string, boolean>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import type { GridColDef, GridRowModel } from '../../types';
|
|
3
|
+
|
|
4
|
+
// --- jsPDF mocks ---
|
|
5
|
+
const mockAutoTable = vi.fn();
|
|
6
|
+
const mockSave = vi.fn();
|
|
7
|
+
const mockSetFontSize = vi.fn();
|
|
8
|
+
const mockSetFont = vi.fn();
|
|
9
|
+
const mockSetTextColor = vi.fn();
|
|
10
|
+
const mockText = vi.fn();
|
|
11
|
+
const mockLine = vi.fn();
|
|
12
|
+
const mockSetDrawColor = vi.fn();
|
|
13
|
+
const mockSetLineWidth = vi.fn();
|
|
14
|
+
const mockAddImage = vi.fn();
|
|
15
|
+
const mockSetPage = vi.fn();
|
|
16
|
+
|
|
17
|
+
class MockJsPDF {
|
|
18
|
+
autoTable = mockAutoTable;
|
|
19
|
+
save = mockSave;
|
|
20
|
+
setFontSize = mockSetFontSize;
|
|
21
|
+
setFont = mockSetFont;
|
|
22
|
+
setTextColor = mockSetTextColor;
|
|
23
|
+
text = mockText;
|
|
24
|
+
line = mockLine;
|
|
25
|
+
setDrawColor = mockSetDrawColor;
|
|
26
|
+
setLineWidth = mockSetLineWidth;
|
|
27
|
+
addImage = mockAddImage;
|
|
28
|
+
setPage = mockSetPage;
|
|
29
|
+
lastAutoTable = { finalY: 50 };
|
|
30
|
+
internal = {
|
|
31
|
+
pageSize: { getWidth: () => 297, getHeight: () => 210 },
|
|
32
|
+
getNumberOfPages: () => 1,
|
|
33
|
+
};
|
|
34
|
+
getNumberOfPages = () => 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
vi.mock('jspdf', () => ({ default: MockJsPDF }));
|
|
38
|
+
vi.mock('jspdf-autotable', () => ({}));
|
|
39
|
+
|
|
40
|
+
const sampleColumns: GridColDef[] = [
|
|
41
|
+
{ field: 'name', headerName: 'Name', width: 150 },
|
|
42
|
+
{ field: 'salary', headerName: 'Salary', width: 120, valueFormatter: ({ value }) => `$${value}` },
|
|
43
|
+
{ field: 'hidden', headerName: 'Hidden', width: 100, exportable: false },
|
|
44
|
+
{ field: '__check__', headerName: '', width: 50 },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const sampleRows: GridRowModel[] = [
|
|
48
|
+
{ id: 1, name: 'Alice', salary: 90000, hidden: 'secret' },
|
|
49
|
+
{ id: 2, name: 'Bob', salary: 80000, hidden: 'secret' },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
describe('exportToPdf', () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
mockAutoTable.mockClear();
|
|
55
|
+
mockSave.mockClear();
|
|
56
|
+
mockText.mockClear();
|
|
57
|
+
mockAddImage.mockClear();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('calls doc.save with the correct filename', async () => {
|
|
61
|
+
const { exportToPdf } = await import('./exportToPdf');
|
|
62
|
+
await exportToPdf(sampleRows, sampleColumns, { fileName: 'my-report' });
|
|
63
|
+
expect(mockSave).toHaveBeenCalledWith('my-report.pdf');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('uses default filename "export.pdf" when not provided', async () => {
|
|
67
|
+
const { exportToPdf } = await import('./exportToPdf');
|
|
68
|
+
await exportToPdf(sampleRows, sampleColumns);
|
|
69
|
+
expect(mockSave).toHaveBeenCalledWith('export.pdf');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('excludes columns with exportable: false and system columns', async () => {
|
|
73
|
+
const { exportToPdf } = await import('./exportToPdf');
|
|
74
|
+
await exportToPdf(sampleRows, sampleColumns);
|
|
75
|
+
const call = mockAutoTable.mock.calls[0][0];
|
|
76
|
+
// head should only have Name and Salary — not Hidden or __check__
|
|
77
|
+
expect(call.head[0]).toEqual(['Name', 'Salary']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('applies valueFormatter to cell values', async () => {
|
|
81
|
+
const { exportToPdf } = await import('./exportToPdf');
|
|
82
|
+
await exportToPdf(sampleRows, sampleColumns);
|
|
83
|
+
const call = mockAutoTable.mock.calls[0][0];
|
|
84
|
+
// salary column should be formatted
|
|
85
|
+
expect(call.body[0][1]).toBe('$90000');
|
|
86
|
+
expect(call.body[1][1]).toBe('$80000');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('filters to selectedRows when provided', async () => {
|
|
90
|
+
const { exportToPdf } = await import('./exportToPdf');
|
|
91
|
+
await exportToPdf(sampleRows, sampleColumns, { selectedRows: [1] });
|
|
92
|
+
const call = mockAutoTable.mock.calls[0][0];
|
|
93
|
+
expect(call.body).toHaveLength(1);
|
|
94
|
+
expect(call.body[0][0]).toBe('Alice');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('appends aggregation footer when aggregationResult is provided', async () => {
|
|
98
|
+
const { exportToPdf } = await import('./exportToPdf');
|
|
99
|
+
await exportToPdf(sampleRows, sampleColumns, {
|
|
100
|
+
aggregationResult: { salary: 170000 },
|
|
101
|
+
aggregationModel: { salary: 'sum' },
|
|
102
|
+
});
|
|
103
|
+
const call = mockAutoTable.mock.calls[0][0];
|
|
104
|
+
expect(call.foot).toBeDefined();
|
|
105
|
+
expect(call.foot[0][1]).toBe('$170000');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('renders title text when title option is set', async () => {
|
|
109
|
+
const { exportToPdf } = await import('./exportToPdf');
|
|
110
|
+
await exportToPdf(sampleRows, sampleColumns, { title: 'Sales Report' });
|
|
111
|
+
// doc.text should have been called with the title
|
|
112
|
+
const textCalls = mockText.mock.calls.map((c: unknown[]) => c[0]);
|
|
113
|
+
expect(textCalls.some((t: unknown) => t === 'Sales Report')).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('throws descriptive error when jspdf is not installed', async () => {
|
|
117
|
+
// This test uses a different mock setup — skip in unit suite,
|
|
118
|
+
// documented as an integration concern
|
|
119
|
+
expect(true).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GridColDef,
|
|
3
|
+
GridRowModel,
|
|
4
|
+
GridFilterModel,
|
|
5
|
+
PdfExportOptions,
|
|
6
|
+
} from '../../types';
|
|
7
|
+
import { formatAggregationValue } from '../../hooks/features/useAggregation';
|
|
8
|
+
|
|
9
|
+
// jsPDF plugin augments the jsPDF prototype — we type only what we use
|
|
10
|
+
interface JsPDFDoc {
|
|
11
|
+
autoTable: (opts: AutoTableOptions) => void;
|
|
12
|
+
save: (filename: string) => void;
|
|
13
|
+
setFontSize: (size: number) => void;
|
|
14
|
+
setFont: (name: string, style: string) => void;
|
|
15
|
+
setTextColor: (r: number, g: number, b: number) => void;
|
|
16
|
+
text: (text: string, x: number, y: number, opts?: { align?: string }) => void;
|
|
17
|
+
addImage: (data: string, format: string, x: number, y: number, w: number, h: number) => void;
|
|
18
|
+
line: (x1: number, y1: number, x2: number, y2: number) => void;
|
|
19
|
+
setDrawColor: (r: number, g: number, b: number) => void;
|
|
20
|
+
setLineWidth: (w: number) => void;
|
|
21
|
+
setPage: (page: number) => void;
|
|
22
|
+
lastAutoTable: { finalY: number };
|
|
23
|
+
internal: {
|
|
24
|
+
pageSize: { getWidth: () => number; getHeight: () => number };
|
|
25
|
+
getNumberOfPages: () => number;
|
|
26
|
+
};
|
|
27
|
+
getNumberOfPages: () => number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface AutoTableOptions {
|
|
31
|
+
head: string[][];
|
|
32
|
+
body: string[][];
|
|
33
|
+
foot?: string[][];
|
|
34
|
+
startY?: number;
|
|
35
|
+
styles?: Record<string, unknown>;
|
|
36
|
+
headStyles?: Record<string, unknown>;
|
|
37
|
+
alternateRowStyles?: Record<string, unknown>;
|
|
38
|
+
footStyles?: Record<string, unknown>;
|
|
39
|
+
columnStyles?: Record<number, { cellWidth: number }>;
|
|
40
|
+
showFoot?: string;
|
|
41
|
+
showHead?: string;
|
|
42
|
+
margin?: { top: number; left: number; right: number; bottom: number };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
46
|
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
47
|
+
if (!result) return [79, 70, 229];
|
|
48
|
+
return [
|
|
49
|
+
parseInt(result[1], 16),
|
|
50
|
+
parseInt(result[2], 16),
|
|
51
|
+
parseInt(result[3], 16),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getExportColumns<R extends GridRowModel>(columns: GridColDef<R>[]): GridColDef<R>[] {
|
|
56
|
+
return columns.filter(col => {
|
|
57
|
+
if (col.exportable === false) return false;
|
|
58
|
+
if (col.field === '__check__' || col.field === '__actions__') return false;
|
|
59
|
+
return true;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveValue<R extends GridRowModel>(row: R, col: GridColDef<R>): string {
|
|
64
|
+
let value: unknown = (row as GridRowModel)[col.field];
|
|
65
|
+
if (col.valueGetter) {
|
|
66
|
+
value = col.valueGetter({ row, field: col.field, value });
|
|
67
|
+
}
|
|
68
|
+
if (col.valueFormatter && value !== undefined && value !== null) {
|
|
69
|
+
value = col.valueFormatter({ value, row, field: col.field });
|
|
70
|
+
}
|
|
71
|
+
return String(value ?? '');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function colWeight<R extends GridRowModel>(col: GridColDef<R>): number {
|
|
75
|
+
const w = typeof col.width === 'number' ? col.width : undefined;
|
|
76
|
+
return w ?? (col.flex ?? 1) * 100;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function computeColumnStyles<R extends GridRowModel>(
|
|
80
|
+
columns: GridColDef<R>[],
|
|
81
|
+
usableWidth: number
|
|
82
|
+
): Record<number, { cellWidth: number }> {
|
|
83
|
+
const totalWeight = columns.reduce((sum, col) => sum + colWeight(col), 0);
|
|
84
|
+
const styles: Record<number, { cellWidth: number }> = {};
|
|
85
|
+
columns.forEach((col, i) => {
|
|
86
|
+
styles[i] = { cellWidth: (colWeight(col) / totalWeight) * usableWidth };
|
|
87
|
+
});
|
|
88
|
+
return styles;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatFilterSummary<R extends GridRowModel>(
|
|
92
|
+
filterModel: GridFilterModel,
|
|
93
|
+
columns: GridColDef<R>[]
|
|
94
|
+
): string {
|
|
95
|
+
if (!filterModel.items || filterModel.items.length === 0) return '';
|
|
96
|
+
return filterModel.items
|
|
97
|
+
.flatMap(item => {
|
|
98
|
+
// Only handle leaf GridFilterItem — skip GridFilterGroup (which has 'items')
|
|
99
|
+
if ('items' in item) return [];
|
|
100
|
+
const col = columns.find(c => c.field === item.field);
|
|
101
|
+
const label = col?.headerName ?? item.field;
|
|
102
|
+
const val = item.value !== undefined && item.value !== null ? String(item.value) : '';
|
|
103
|
+
return [`${label} ${item.operator} "${val}"`];
|
|
104
|
+
})
|
|
105
|
+
.join(' • ');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function exportToPdf<R extends GridRowModel>(
|
|
109
|
+
rows: R[],
|
|
110
|
+
columns: GridColDef<R>[],
|
|
111
|
+
options: PdfExportOptions = {}
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
// Lazy-load peer deps — provides a clear error if not installed
|
|
114
|
+
let JsPDF: new (opts: Record<string, unknown>) => JsPDFDoc;
|
|
115
|
+
try {
|
|
116
|
+
const mod = await import('jspdf');
|
|
117
|
+
JsPDF = mod.default as new (opts: Record<string, unknown>) => JsPDFDoc;
|
|
118
|
+
await import('jspdf-autotable');
|
|
119
|
+
} catch {
|
|
120
|
+
throw new Error(
|
|
121
|
+
"exportToPdf requires 'jspdf' and 'jspdf-autotable'. Run: npm install jspdf jspdf-autotable"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const {
|
|
126
|
+
fileName = 'export',
|
|
127
|
+
title,
|
|
128
|
+
logoUrl,
|
|
129
|
+
orientation = 'landscape',
|
|
130
|
+
selectedRows,
|
|
131
|
+
aggregationResult,
|
|
132
|
+
aggregationModel,
|
|
133
|
+
filterModel,
|
|
134
|
+
alternateRowColor = true,
|
|
135
|
+
headerBackgroundColor = '#4f46e5',
|
|
136
|
+
headerTextColor = '#ffffff',
|
|
137
|
+
fontSize = 9,
|
|
138
|
+
} = options;
|
|
139
|
+
|
|
140
|
+
const doc = new JsPDF({ orientation, unit: 'mm', format: 'a4' });
|
|
141
|
+
const pageWidth = doc.internal.pageSize.getWidth();
|
|
142
|
+
const pageHeight = doc.internal.pageSize.getHeight();
|
|
143
|
+
const MARGIN = 14;
|
|
144
|
+
const usableWidth = pageWidth - MARGIN * 2;
|
|
145
|
+
|
|
146
|
+
const exportColumns = getExportColumns(columns);
|
|
147
|
+
const rowsToExport = selectedRows && selectedRows.length > 0
|
|
148
|
+
? rows.filter(r => selectedRows.includes(r.id as string | number))
|
|
149
|
+
: rows;
|
|
150
|
+
|
|
151
|
+
// --- Header block ---
|
|
152
|
+
let startY = MARGIN;
|
|
153
|
+
|
|
154
|
+
if (title) {
|
|
155
|
+
let cursorX = MARGIN;
|
|
156
|
+
let cursorY = MARGIN + 8;
|
|
157
|
+
|
|
158
|
+
// Logo
|
|
159
|
+
if (logoUrl) {
|
|
160
|
+
try {
|
|
161
|
+
doc.addImage(logoUrl, 'PNG', cursorX, MARGIN, 10, 10);
|
|
162
|
+
cursorX += 14;
|
|
163
|
+
} catch {
|
|
164
|
+
// logo failed to load — skip silently
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Title
|
|
169
|
+
doc.setFontSize(16);
|
|
170
|
+
doc.setFont('helvetica', 'bold');
|
|
171
|
+
doc.setTextColor(30, 41, 59); // #1e293b
|
|
172
|
+
doc.text(title, cursorX, cursorY);
|
|
173
|
+
cursorY += 6;
|
|
174
|
+
|
|
175
|
+
// Subtitle: date + row count
|
|
176
|
+
doc.setFontSize(8);
|
|
177
|
+
doc.setFont('helvetica', 'normal');
|
|
178
|
+
doc.setTextColor(100, 116, 139); // #64748b
|
|
179
|
+
const exportedAt = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
180
|
+
doc.text(
|
|
181
|
+
`Exported: ${exportedAt} • ${rowsToExport.length} row${rowsToExport.length !== 1 ? 's' : ''}`,
|
|
182
|
+
cursorX,
|
|
183
|
+
cursorY
|
|
184
|
+
);
|
|
185
|
+
cursorY += 5;
|
|
186
|
+
|
|
187
|
+
// Filter summary
|
|
188
|
+
if (filterModel) {
|
|
189
|
+
const summary = formatFilterSummary(filterModel, columns);
|
|
190
|
+
if (summary) {
|
|
191
|
+
doc.text(`Filters: ${summary}`, cursorX, cursorY);
|
|
192
|
+
cursorY += 5;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Separator line
|
|
197
|
+
doc.setDrawColor(226, 232, 240); // #e2e8f0
|
|
198
|
+
doc.setLineWidth(0.3);
|
|
199
|
+
doc.line(MARGIN, cursorY + 2, pageWidth - MARGIN, cursorY + 2);
|
|
200
|
+
startY = cursorY + 6;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// --- Build table data ---
|
|
204
|
+
const head = [exportColumns.map(col => col.headerName ?? col.field)];
|
|
205
|
+
|
|
206
|
+
const body = rowsToExport.map(row =>
|
|
207
|
+
exportColumns.map(col => resolveValue(row, col))
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// --- Aggregation footer ---
|
|
211
|
+
let foot: string[][] | undefined;
|
|
212
|
+
if (aggregationResult && aggregationModel) {
|
|
213
|
+
const footRow = exportColumns.map((col, i) => {
|
|
214
|
+
if (i === 0) {
|
|
215
|
+
// First column shows TOTAL label if any aggregation exists
|
|
216
|
+
const hasAgg = exportColumns.some(c => aggregationModel[c.field]);
|
|
217
|
+
return hasAgg ? 'TOTAL' : '';
|
|
218
|
+
}
|
|
219
|
+
const aggVal = aggregationResult[col.field];
|
|
220
|
+
if (aggVal === undefined || aggVal === null) return '';
|
|
221
|
+
let formatted: unknown = aggVal;
|
|
222
|
+
if (col.valueFormatter) {
|
|
223
|
+
formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
|
|
224
|
+
} else if (aggregationModel[col.field]) {
|
|
225
|
+
formatted = formatAggregationValue(aggVal, aggregationModel[col.field] as string);
|
|
226
|
+
}
|
|
227
|
+
return String(formatted);
|
|
228
|
+
});
|
|
229
|
+
foot = [footRow];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// --- Column widths ---
|
|
233
|
+
const columnStyles = computeColumnStyles(exportColumns, usableWidth);
|
|
234
|
+
|
|
235
|
+
const [hr, hg, hb] = hexToRgb(headerBackgroundColor);
|
|
236
|
+
const [tr, tg, tb] = hexToRgb(headerTextColor);
|
|
237
|
+
|
|
238
|
+
doc.autoTable({
|
|
239
|
+
head,
|
|
240
|
+
body,
|
|
241
|
+
...(foot ? { foot } : {}),
|
|
242
|
+
startY,
|
|
243
|
+
styles: { fontSize, cellPadding: 3, font: 'helvetica', overflow: 'linebreak' },
|
|
244
|
+
headStyles: {
|
|
245
|
+
fillColor: [hr, hg, hb],
|
|
246
|
+
textColor: [tr, tg, tb],
|
|
247
|
+
fontStyle: 'bold',
|
|
248
|
+
},
|
|
249
|
+
...(alternateRowColor
|
|
250
|
+
? { alternateRowStyles: { fillColor: [248, 250, 252] } }
|
|
251
|
+
: {}),
|
|
252
|
+
...(foot
|
|
253
|
+
? { footStyles: { fillColor: [241, 245, 249], fontStyle: 'bold' } }
|
|
254
|
+
: {}),
|
|
255
|
+
columnStyles,
|
|
256
|
+
showFoot: foot ? 'lastPage' : 'never',
|
|
257
|
+
showHead: 'everyPage',
|
|
258
|
+
margin: { top: MARGIN, left: MARGIN, right: MARGIN, bottom: MARGIN + 6 },
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Two-pass page numbering — stamp all pages after the table is fully rendered
|
|
262
|
+
// so each stamp reads the correct final total (not the running count during draw)
|
|
263
|
+
const totalPages = doc.getNumberOfPages();
|
|
264
|
+
for (let i = 1; i <= totalPages; i++) {
|
|
265
|
+
doc.setPage(i);
|
|
266
|
+
doc.setFontSize(8);
|
|
267
|
+
doc.setTextColor(150, 150, 150);
|
|
268
|
+
doc.text(
|
|
269
|
+
`Page ${i} of ${totalPages}`,
|
|
270
|
+
pageWidth - MARGIN,
|
|
271
|
+
pageHeight - 8,
|
|
272
|
+
{ align: 'right' }
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
doc.save(`${fileName}.pdf`);
|
|
277
|
+
}
|
|
@@ -553,3 +553,6 @@ function downloadFile(content: string, fileName: string, mimeType: string): void
|
|
|
553
553
|
document.body.removeChild(link);
|
|
554
554
|
URL.revokeObjectURL(url);
|
|
555
555
|
}
|
|
556
|
+
|
|
557
|
+
export { exportToPdf } from './exportToPdf';
|
|
558
|
+
export type { PdfExportOptions } from '../../types';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Ambient declarations for optional peer dependencies.
|
|
2
|
+
// These packages are listed in peerDependenciesMeta as optional and are NOT
|
|
3
|
+
// installed in devDependencies. The declarations here let TypeScript accept
|
|
4
|
+
// the dynamic imports without requiring the packages at build time.
|
|
5
|
+
// This file is excluded from the shipped .d.ts rollup so it does not
|
|
6
|
+
// conflict with a consumer's real jspdf types.
|
|
7
|
+
|
|
8
|
+
declare module 'jspdf' {
|
|
9
|
+
// Minimal shape — exportToPdf.ts only uses the JsPDFDoc interface it defines.
|
|
10
|
+
// The full jsPDF types are available when the consumer installs the package.
|
|
11
|
+
const JsPDF: new (opts?: Record<string, unknown>) => unknown;
|
|
12
|
+
export default JsPDF;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare module 'jspdf-autotable' {
|
|
16
|
+
// Side-effect import that augments jsPDF.prototype.autoTable.
|
|
17
|
+
const autoTable: unknown;
|
|
18
|
+
export default autoTable;
|
|
19
|
+
}
|