diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 204b8ea..1709ea3 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.0] - 2026-05-22 + +### Added +- Files module (EXTENDED): `provideFiles(config?)` factory with `FILES_CONFIG` injection token +- `FileValidationService` — validates file size, MIME type, and extension with configurable rules +- `FilePickerService` — programmatic native file picker via hidden `` +- `FileUploadService` — HTTP POST upload with real-time progress tracking via `Observable` +- `FileDownloadService` — triggers browser downloads from URLs or in-memory Blobs +- `FilePreviewService` — blob URL lifecycle management with `createBlobUrl()`, `revokeBlobUrl()`, `revokeAll()` +- `FileExportService` — orchestrates tabular data export (CSV/PDF/Excel) with automatic format selection and download +- `exportToCsv()` — pure JS CSV exporter with UTF-8 BOM, configurable separators, field escaping +- `exportToPdf()` — PDF exporter via dynamic `import('jspdf')` with title, autoTable layout, column widths +- `exportToExcel()` — Excel exporter via dynamic `import('exceljs')` with styled headers, sheet naming +- Types: `ExportFormat`, `ExportConfig`, `ExportColumn`, `UploadProgress`, `FileValidationConfig`, `ValidationResult`, `FilesConfig` +- Optional peer dependencies: `jspdf >=2.5.0`, `jspdf-autotable >=3.8.0`, `exceljs >=4.4.0` + ## [0.12.0] - 2026-05-20 ### Added diff --git a/packages/core/eslint.config.mjs b/packages/core/eslint.config.mjs index 1e626ce..e894107 100644 --- a/packages/core/eslint.config.mjs +++ b/packages/core/eslint.config.mjs @@ -19,6 +19,9 @@ export default [ '@angular/common', '@angular/router', 'rxjs', + 'jspdf', + 'jspdf-autotable', + 'exceljs', ], }, ], diff --git a/packages/core/package.json b/packages/core/package.json index d541bdd..4132f7b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@fireflyframework/core", - "version": "0.12.0", + "version": "0.13.0", "publishConfig": { "registry": "https://npm.pkg.github.com" }, @@ -16,12 +16,24 @@ "rxjs": "~7.8.0", "@jsverse/transloco": "^8.3.0", "@fireflyframework/utils": ">=0.1.0", - "@jsverse/transloco-messageformat": "^8.3.0" + "@jsverse/transloco-messageformat": "^8.3.0", + "jspdf": ">=2.5.0", + "jspdf-autotable": ">=3.8.0", + "exceljs": ">=4.4.0" }, "sideEffects": false, "peerDependenciesMeta": { "@jsverse/transloco-messageformat": { "optional": true + }, + "jspdf": { + "optional": true + }, + "jspdf-autotable": { + "optional": true + }, + "exceljs": { + "optional": true } } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8eb002b..54d97a6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,3 +13,4 @@ export * from './lib/feature-flags'; export * from './lib/storage'; export * from './lib/environment'; export * from './lib/security'; +export * from './lib/files'; diff --git a/packages/core/src/lib/files/exporters/csv-exporter.spec.ts b/packages/core/src/lib/files/exporters/csv-exporter.spec.ts new file mode 100644 index 0000000..cd7a620 --- /dev/null +++ b/packages/core/src/lib/files/exporters/csv-exporter.spec.ts @@ -0,0 +1,160 @@ +import { exportToCsv } from './csv-exporter'; +import { ExportConfig } from '../file.types'; + +interface TestRow { + name: string; + email: string; + age: number; +} + +const columns: ExportConfig['columns'] = [ + { key: 'name', header: 'Name' }, + { key: 'email', header: 'Email' }, + { key: 'age', header: 'Age' }, +]; + +function blobToText(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); +} + +describe('exportToCsv', () => { + const baseConfig: ExportConfig = { + format: 'csv', + filename: 'test', + columns, + }; + + it('should generate CSV with headers and data rows', async () => { + const data: TestRow[] = [ + { name: 'Alice', email: 'alice@test.com', age: 30 }, + { name: 'Bob', email: 'bob@test.com', age: 25 }, + ]; + + const blob = exportToCsv(data, baseConfig); + const text = await blobToText(blob); + + expect(text).toContain('Name,Email,Age'); + expect(text).toContain('Alice,alice@test.com,30'); + expect(text).toContain('Bob,bob@test.com,25'); + }); + + it('should include UTF-8 BOM as first bytes', async () => { + const blob = exportToCsv([], baseConfig); + const buffer = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as ArrayBuffer); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(blob); + }); + const bytes = new Uint8Array(buffer); + // UTF-8 BOM = EF BB BF + expect(bytes[0]).toBe(0xef); + expect(bytes[1]).toBe(0xbb); + expect(bytes[2]).toBe(0xbf); + }); + + it('should have correct MIME type', () => { + const blob = exportToCsv([], baseConfig); + expect(blob.type).toBe('text/csv;charset=utf-8'); + }); + + it('should use comma as default separator', async () => { + const data: TestRow[] = [{ name: 'Alice', email: 'a@b.com', age: 30 }]; + const blob = exportToCsv(data, baseConfig); + const text = await blobToText(blob); + + const lines = text.split('\r\n'); + expect(lines[0]).toContain('Name,Email,Age'); + expect(lines[1]).toBe('Alice,a@b.com,30'); + }); + + it('should support semicolon separator', async () => { + const config: ExportConfig = { ...baseConfig, csvSeparator: ';' }; + const data: TestRow[] = [{ name: 'Alice', email: 'a@b.com', age: 30 }]; + const blob = exportToCsv(data, config); + const text = await blobToText(blob); + + const lines = text.split('\r\n'); + expect(lines[0]).toContain('Name;Email;Age'); + expect(lines[1]).toBe('Alice;a@b.com;30'); + }); + + it('should support tab separator', async () => { + const config: ExportConfig = { ...baseConfig, csvSeparator: '\t' }; + const data: TestRow[] = [{ name: 'Alice', email: 'a@b.com', age: 30 }]; + const blob = exportToCsv(data, config); + const text = await blobToText(blob); + + const lines = text.split('\r\n'); + expect(lines[0]).toContain('Name\tEmail\tAge'); + expect(lines[1]).toBe('Alice\ta@b.com\t30'); + }); + + it('should escape fields containing the separator', async () => { + const data: TestRow[] = [{ name: 'Doe, Jane', email: 'j@b.com', age: 28 }]; + const blob = exportToCsv(data, baseConfig); + const text = await blobToText(blob); + + expect(text).toContain('"Doe, Jane"'); + }); + + it('should escape fields containing double quotes', async () => { + const data: TestRow[] = [{ name: 'She said "hi"', email: 'a@b.com', age: 25 }]; + const blob = exportToCsv(data, baseConfig); + const text = await blobToText(blob); + + expect(text).toContain('"She said ""hi"""'); + }); + + it('should escape fields containing newlines', async () => { + const data: TestRow[] = [{ name: 'Line1\nLine2', email: 'a@b.com', age: 25 }]; + const blob = exportToCsv(data, baseConfig); + const text = await blobToText(blob); + + expect(text).toContain('"Line1\nLine2"'); + }); + + it('should handle null/undefined values as empty strings', async () => { + interface Partial { name: string; note: string | null } + const config: ExportConfig = { + format: 'csv', + filename: 'test', + columns: [ + { key: 'name', header: 'Name' }, + { key: 'note', header: 'Note' }, + ], + }; + const data = [{ name: 'Alice', note: null }] as Partial[]; + const blob = exportToCsv(data, config); + const text = await blobToText(blob); + + const lines = text.split('\r\n'); + expect(lines[1]).toBe('Alice,'); + }); + + it('should handle empty data array (headers only)', async () => { + const blob = exportToCsv([], baseConfig); + const text = await blobToText(blob); + + const lines = text.split('\r\n'); + expect(lines.length).toBe(1); + expect(lines[0]).toContain('Name,Email,Age'); + }); + + it('should use CRLF line endings', async () => { + const data: TestRow[] = [ + { name: 'Alice', email: 'a@b.com', age: 30 }, + { name: 'Bob', email: 'b@b.com', age: 25 }, + ]; + const blob = exportToCsv(data, baseConfig); + const text = await blobToText(blob); + + const crlfCount = (text.match(/\r\n/g) || []).length; + expect(crlfCount).toBe(2); + }); +}); diff --git a/packages/core/src/lib/files/exporters/csv-exporter.ts b/packages/core/src/lib/files/exporters/csv-exporter.ts new file mode 100644 index 0000000..8b92b85 --- /dev/null +++ b/packages/core/src/lib/files/exporters/csv-exporter.ts @@ -0,0 +1,70 @@ +import { ExportConfig } from '../file.types'; + +/** UTF-8 BOM for Excel compatibility. */ +const UTF8_BOM = '\uFEFF'; + +/** + * Escape a CSV field value. + * + * If the value contains the separator, a double-quote, or a newline, + * it is wrapped in double-quotes with internal quotes escaped. + */ +function escapeField(value: string, separator: string): string { + if ( + value.includes(separator) || + value.includes('"') || + value.includes('\n') || + value.includes('\r') + ) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +/** + * Export tabular data to a CSV Blob. + * + * Pure JS implementation with no external dependencies. Supports + * configurable field separators and includes a UTF-8 BOM for + * Excel compatibility. + * + * @param data — Array of data objects to export. + * @param config — Export configuration with columns and optional csvSeparator. + * @returns A `Blob` with MIME type `text/csv;charset=utf-8`. + * + * @example + * ```typescript + * const blob = exportToCsv(users, { + * format: 'csv', + * filename: 'users', + * columns: [ + * { key: 'name', header: 'Name' }, + * { key: 'email', header: 'Email' }, + * ], + * }); + * ``` + */ +export function exportToCsv(data: T[], config: ExportConfig): Blob { + const separator = config.csvSeparator ?? ','; + const columns = config.columns; + + // Header row + const headerRow = columns + .map(col => escapeField(col.header, separator)) + .join(separator); + + // Data rows + const dataRows = data.map(row => + columns + .map(col => { + const value = row[col.key]; + const str = value == null ? '' : String(value); + return escapeField(str, separator); + }) + .join(separator), + ); + + const csvContent = UTF8_BOM + [headerRow, ...dataRows].join('\r\n'); + + return new Blob([csvContent], { type: 'text/csv;charset=utf-8' }); +} diff --git a/packages/core/src/lib/files/exporters/excel-exporter.spec.ts b/packages/core/src/lib/files/exporters/excel-exporter.spec.ts new file mode 100644 index 0000000..813f09c --- /dev/null +++ b/packages/core/src/lib/files/exporters/excel-exporter.spec.ts @@ -0,0 +1,151 @@ +import { ExportConfig } from '../file.types'; + +interface TestRow { + name: string; + email: string; +} + +const baseConfig: ExportConfig = { + format: 'xlsx', + filename: 'test', + columns: [ + { key: 'name', header: 'Name' }, + { key: 'email', header: 'Email' }, + ], +}; + +// Shared mock state +let mockWorksheet: any; +let mockWorkbook: any; + +vi.mock('exceljs', () => { + function MockWorkbook() { return mockWorkbook; } + return { Workbook: MockWorkbook, default: { Workbook: MockWorkbook } }; +}); + +describe('exportToExcel', () => { + beforeEach(() => { + mockWorksheet = { + columns: [], + getRow: vi.fn().mockReturnValue({ + font: {}, + fill: {}, + alignment: {}, + }), + addRow: vi.fn(), + }; + + mockWorkbook = { + addWorksheet: vi.fn().mockReturnValue(mockWorksheet), + title: undefined, + xlsx: { + writeBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(10)), + }, + }; + }); + + async function run(data: T[], config: ExportConfig) { + const { exportToExcel } = await import('./excel-exporter'); + return exportToExcel(data, config); + } + + it('should generate an XLSX blob', async () => { + const blob = await run([{ name: 'Alice', email: 'a@b.com' }], baseConfig); + + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + }); + + it('should create a worksheet with the default sheet name', async () => { + await run([], baseConfig); + + expect(mockWorkbook.addWorksheet).toHaveBeenCalledWith('Sheet1'); + }); + + it('should use custom sheet name when provided', async () => { + await run([], { ...baseConfig, sheetName: 'Users' }); + + expect(mockWorkbook.addWorksheet).toHaveBeenCalledWith('Users'); + }); + + it('should set column definitions with headers and keys', async () => { + await run([], baseConfig); + + expect(mockWorksheet.columns).toEqual([ + { header: 'Name', key: 'name', width: 20 }, + { header: 'Email', key: 'email', width: 20 }, + ]); + }); + + it('should apply custom column widths', async () => { + const config: ExportConfig = { + ...baseConfig, + columns: [ + { key: 'name', header: 'Name', width: 30 }, + { key: 'email', header: 'Email', width: 40 }, + ], + }; + await run([], config); + + expect(mockWorksheet.columns).toEqual([ + { header: 'Name', key: 'name', width: 30 }, + { header: 'Email', key: 'email', width: 40 }, + ]); + }); + + it('should style the header row as bold', async () => { + const headerRow = { font: {}, fill: {}, alignment: {} }; + mockWorksheet.getRow = vi.fn().mockReturnValue(headerRow); + await run([], baseConfig); + + expect(mockWorksheet.getRow).toHaveBeenCalledWith(1); + expect(headerRow.font).toEqual({ bold: true }); + }); + + it('should add data rows with string values', async () => { + const data: TestRow[] = [ + { name: 'Alice', email: 'alice@test.com' }, + { name: 'Bob', email: 'bob@test.com' }, + ]; + await run(data, baseConfig); + + expect(mockWorksheet.addRow).toHaveBeenCalledTimes(2); + expect(mockWorksheet.addRow).toHaveBeenCalledWith({ name: 'Alice', email: 'alice@test.com' }); + expect(mockWorksheet.addRow).toHaveBeenCalledWith({ name: 'Bob', email: 'bob@test.com' }); + }); + + it('should handle null values as empty strings', async () => { + interface Partial { name: string; note: string | null } + const config: ExportConfig = { + format: 'xlsx', filename: 'test', + columns: [{ key: 'name', header: 'Name' }, { key: 'note', header: 'Note' }], + }; + await run([{ name: 'Alice', note: null } as Partial], config); + + expect(mockWorksheet.addRow).toHaveBeenCalledWith({ name: 'Alice', note: '' }); + }); + + it('should set workbook title when provided', async () => { + await run([], { ...baseConfig, title: 'My Report' }); + + expect(mockWorkbook.title).toBe('My Report'); + }); + + it('should not set workbook title when not provided', async () => { + await run([], baseConfig); + + expect(mockWorkbook.title).toBeUndefined(); + }); + + it('should call writeBuffer to generate the output', async () => { + await run([], baseConfig); + + expect(mockWorkbook.xlsx.writeBuffer).toHaveBeenCalled(); + }); + + it('should have descriptive error message for missing exceljs', () => { + const msg = 'ExcelExporter: "exceljs" is not installed. Install it with: npm install exceljs'; + expect(msg).toMatch(/exceljs.*not installed/i); + expect(msg).toContain('npm install exceljs'); + }); +}); diff --git a/packages/core/src/lib/files/exporters/excel-exporter.ts b/packages/core/src/lib/files/exporters/excel-exporter.ts new file mode 100644 index 0000000..75fe6af --- /dev/null +++ b/packages/core/src/lib/files/exporters/excel-exporter.ts @@ -0,0 +1,83 @@ +import { ExportConfig } from '../file.types'; + +/** + * Export tabular data to an Excel (XLSX) Blob. + * + * Uses `exceljs` loaded via dynamic import for tree-shaking. + * If the package is not installed, a descriptive error is thrown. + * + * @param data — Array of data objects to export. + * @param config — Export configuration with columns, optional sheetName and title. + * @returns A `Promise` with MIME type for XLSX. + * + * @example + * ```typescript + * const blob = await exportToExcel(users, { + * format: 'xlsx', + * filename: 'users-report', + * title: 'User Report', + * sheetName: 'Users', + * columns: [ + * { key: 'name', header: 'Name', width: 30 }, + * { key: 'email', header: 'Email', width: 40 }, + * ], + * }); + * ``` + */ +export async function exportToExcel(data: T[], config: ExportConfig): Promise { + let ExcelJS: any; + + try { + // @ts-expect-error — optional peer dependency, resolved at runtime + ExcelJS = await import('exceljs'); + } catch { + throw new Error( + 'ExcelExporter: "exceljs" is not installed. Install it with: npm install exceljs', + ); + } + + const Workbook = ExcelJS.Workbook ?? ExcelJS.default?.Workbook; + if (!Workbook) { + throw new Error('ExcelExporter: Could not resolve exceljs Workbook class.'); + } + + const workbook = new Workbook(); + const sheetName = config.sheetName ?? 'Sheet1'; + const worksheet = workbook.addWorksheet(sheetName); + + // Column definitions + worksheet.columns = config.columns.map(col => ({ + header: col.header, + key: col.key, + width: col.width ?? 20, + })); + + // Style header row (bold + fill) + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF2980B9' }, + }; + headerRow.alignment = { vertical: 'middle' }; + + // Add data rows + for (const row of data) { + const values: Record = {}; + for (const col of config.columns) { + const value = row[col.key]; + values[col.key] = value == null ? '' : String(value); + } + worksheet.addRow(values); + } + + // Title as sheet header (if provided) + if (config.title) { + workbook.title = config.title; + } + + const buffer = await workbook.xlsx.writeBuffer(); + const xlsxMime = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + return new Blob([buffer], { type: xlsxMime }); +} diff --git a/packages/core/src/lib/files/exporters/pdf-exporter.spec.ts b/packages/core/src/lib/files/exporters/pdf-exporter.spec.ts new file mode 100644 index 0000000..2457ffe --- /dev/null +++ b/packages/core/src/lib/files/exporters/pdf-exporter.spec.ts @@ -0,0 +1,127 @@ +import { ExportConfig } from '../file.types'; + +interface TestRow { + name: string; + email: string; +} + +const baseConfig: ExportConfig = { + format: 'pdf', + filename: 'test', + columns: [ + { key: 'name', header: 'Name' }, + { key: 'email', header: 'Email' }, + ], +}; + +// Shared mock state — mutated by the factory, read by tests +let mockDoc: any; +let mockAutoTable: ReturnType; + +vi.mock('jspdf', () => { + function MockJsPDF() { return mockDoc; } + return { default: MockJsPDF, jsPDF: MockJsPDF }; +}); + +vi.mock('jspdf-autotable', () => ({ + default: (...args: any[]) => mockAutoTable(...args), +})); + +describe('exportToPdf', () => { + beforeEach(() => { + mockDoc = { + setFontSize: vi.fn(), + text: vi.fn(), + output: vi.fn().mockReturnValue(new Blob(['%PDF'], { type: 'application/pdf' })), + }; + mockAutoTable = vi.fn(); + }); + + // Lazy import so mocks are in place + async function run(data: T[], config: ExportConfig) { + const { exportToPdf } = await import('./pdf-exporter'); + return exportToPdf(data, config); + } + + it('should generate a PDF blob', async () => { + const blob = await run([{ name: 'Alice', email: 'a@b.com' }], baseConfig); + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe('application/pdf'); + }); + + it('should call autoTable with head and body', async () => { + await run([{ name: 'Alice', email: 'alice@test.com' }], baseConfig); + + expect(mockAutoTable).toHaveBeenCalledTimes(1); + const args = mockAutoTable.mock.calls[0]; + expect(args[0]).toBe(mockDoc); + expect(args[1].head).toEqual([['Name', 'Email']]); + expect(args[1].body).toEqual([['Alice', 'alice@test.com']]); + }); + + it('should add title when provided', async () => { + await run([], { ...baseConfig, title: 'My Report' }); + + expect(mockDoc.setFontSize).toHaveBeenCalledWith(16); + expect(mockDoc.text).toHaveBeenCalledWith('My Report', 14, 20); + }); + + it('should not add title when not provided', async () => { + await run([], baseConfig); + + expect(mockDoc.setFontSize).not.toHaveBeenCalled(); + expect(mockDoc.text).not.toHaveBeenCalled(); + }); + + it('should set startY=30 after title', async () => { + await run([], { ...baseConfig, title: 'Report' }); + expect(mockAutoTable.mock.calls[0][1].startY).toBe(30); + }); + + it('should set startY=14 when no title', async () => { + await run([], baseConfig); + expect(mockAutoTable.mock.calls[0][1].startY).toBe(14); + }); + + it('should apply column widths when specified', async () => { + const config: ExportConfig = { + ...baseConfig, + columns: [ + { key: 'name', header: 'Name', width: 80 }, + { key: 'email', header: 'Email', width: 120 }, + ], + }; + await run([], config); + + expect(mockAutoTable.mock.calls[0][1].columnStyles).toEqual({ + 0: { cellWidth: 80 }, + 1: { cellWidth: 120 }, + }); + }); + + it('should handle null values as empty strings', async () => { + interface Partial { name: string; note: string | null } + const config: ExportConfig = { + format: 'pdf', filename: 'test', + columns: [{ key: 'name', header: 'Name' }, { key: 'note', header: 'Note' }], + }; + await run([{ name: 'Alice', note: null } as Partial], config); + + expect(mockAutoTable.mock.calls[0][1].body).toEqual([['Alice', '']]); + }); + + it('should have descriptive error message for missing jspdf', () => { + const msg = 'PdfExporter: "jspdf" is not installed. Install it with: npm install jspdf jspdf-autotable'; + expect(msg).toMatch(/jspdf.*not installed/i); + }); + + it('should have descriptive error message for missing jspdf-autotable', () => { + const msg = 'PdfExporter: "jspdf-autotable" is not installed. Install it with: npm install jspdf-autotable'; + expect(msg).toMatch(/jspdf-autotable.*not installed/i); + }); + + it('should call doc.output with blob format', async () => { + await run([], baseConfig); + expect(mockDoc.output).toHaveBeenCalledWith('blob'); + }); +}); diff --git a/packages/core/src/lib/files/exporters/pdf-exporter.ts b/packages/core/src/lib/files/exporters/pdf-exporter.ts new file mode 100644 index 0000000..e8e885d --- /dev/null +++ b/packages/core/src/lib/files/exporters/pdf-exporter.ts @@ -0,0 +1,87 @@ +import { ExportConfig } from '../file.types'; + +/** + * Export tabular data to a PDF Blob. + * + * Uses `jspdf` and `jspdf-autotable` loaded via dynamic import for + * tree-shaking. If these packages are not installed, a descriptive + * error is thrown. + * + * @param data — Array of data objects to export. + * @param config — Export configuration with columns and optional title. + * @returns A `Promise` with MIME type `application/pdf`. + * + * @example + * ```typescript + * const blob = await exportToPdf(users, { + * format: 'pdf', + * filename: 'users-report', + * title: 'User Report', + * columns: [ + * { key: 'name', header: 'Name', width: 80 }, + * { key: 'email', header: 'Email', width: 120 }, + * ], + * }); + * ``` + */ +export async function exportToPdf(data: T[], config: ExportConfig): Promise { + let jsPDF: any; + let autoTable: any; + + try { + // @ts-expect-error — optional peer dependency, resolved at runtime + const jspdfModule = await import('jspdf'); + jsPDF = jspdfModule.default ?? jspdfModule.jsPDF; + } catch { + throw new Error( + 'PdfExporter: "jspdf" is not installed. Install it with: npm install jspdf jspdf-autotable', + ); + } + + try { + // @ts-expect-error — optional peer dependency, resolved at runtime + const autoTableModule = await import('jspdf-autotable'); + autoTable = autoTableModule.default ?? autoTableModule; + } catch { + throw new Error( + 'PdfExporter: "jspdf-autotable" is not installed. Install it with: npm install jspdf-autotable', + ); + } + + const doc = new jsPDF(); + + // Title + if (config.title) { + doc.setFontSize(16); + doc.text(config.title, 14, 20); + } + + // Table + const startY = config.title ? 30 : 14; + const head = [config.columns.map(col => col.header)]; + const body = data.map(row => + config.columns.map(col => { + const value = row[col.key]; + return value == null ? '' : String(value); + }), + ); + + const columnStyles: Record = {}; + config.columns.forEach((col, i) => { + if (col.width) { + columnStyles[i] = { cellWidth: col.width }; + } + }); + + autoTable(doc, { + startY, + head, + body, + columnStyles: Object.keys(columnStyles).length > 0 ? columnStyles : undefined, + styles: { fontSize: 10 }, + headStyles: { fillColor: [41, 128, 185] }, + margin: { top: 14 }, + }); + + return doc.output('blob'); +} diff --git a/packages/core/src/lib/files/file-download.service.spec.ts b/packages/core/src/lib/files/file-download.service.spec.ts new file mode 100644 index 0000000..ddc8016 --- /dev/null +++ b/packages/core/src/lib/files/file-download.service.spec.ts @@ -0,0 +1,125 @@ +import { TestBed } from '@angular/core/testing'; +import { DOCUMENT } from '@angular/common'; +import { FileDownloadService } from './file-download.service'; + +describe('FileDownloadService', () => { + let svc: FileDownloadService; + let mockAnchor: { + href: string; + download: string; + style: { display: string }; + click: ReturnType; + }; + let mockDoc: { + createElement: ReturnType; + body: { appendChild: ReturnType; removeChild: ReturnType }; + }; + + beforeEach(() => { + mockAnchor = { + href: '', + download: '', + style: { display: '' }, + click: vi.fn(), + }; + + mockDoc = { + createElement: vi.fn().mockReturnValue(mockAnchor), + body: { + appendChild: vi.fn(), + removeChild: vi.fn(), + }, + }; + + TestBed.configureTestingModule({ + providers: [ + FileDownloadService, + { provide: DOCUMENT, useValue: mockDoc }, + ], + }); + + svc = TestBed.inject(FileDownloadService); + }); + + // ------------------------------------------------------------------- + // download() + // ------------------------------------------------------------------- + describe('download()', () => { + it('should create an anchor element', () => { + svc.download('/api/file/1', 'report.pdf'); + expect(mockDoc.createElement).toHaveBeenCalledWith('a'); + }); + + it('should set href and download attributes', () => { + svc.download('/api/file/1', 'report.pdf'); + expect(mockAnchor.href).toBe('/api/file/1'); + expect(mockAnchor.download).toBe('report.pdf'); + }); + + it('should hide the anchor element', () => { + svc.download('/api/file/1', 'report.pdf'); + expect(mockAnchor.style.display).toBe('none'); + }); + + it('should append anchor to body, click it, and remove it', () => { + svc.download('/api/file/1', 'report.pdf'); + + expect(mockDoc.body.appendChild).toHaveBeenCalledWith(mockAnchor); + expect(mockAnchor.click).toHaveBeenCalled(); + expect(mockDoc.body.removeChild).toHaveBeenCalledWith(mockAnchor); + + // Verify order: append -> click -> remove + const appendOrder = mockDoc.body.appendChild.mock.invocationCallOrder[0]; + const clickOrder = mockAnchor.click.mock.invocationCallOrder[0]; + const removeOrder = mockDoc.body.removeChild.mock.invocationCallOrder[0]; + expect(appendOrder).toBeLessThan(clickOrder); + expect(clickOrder).toBeLessThan(removeOrder); + }); + }); + + // ------------------------------------------------------------------- + // downloadBlob() + // ------------------------------------------------------------------- + describe('downloadBlob()', () => { + let createObjectURLSpy: ReturnType; + let revokeObjectURLSpy: ReturnType; + + beforeEach(() => { + createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => { /* noop */ }); + }); + + afterEach(() => { + createObjectURLSpy.mockRestore(); + revokeObjectURLSpy.mockRestore(); + }); + + it('should create a blob URL from the blob', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + svc.downloadBlob(blob, 'test.txt'); + expect(createObjectURLSpy).toHaveBeenCalledWith(blob); + }); + + it('should download using the blob URL', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + svc.downloadBlob(blob, 'test.txt'); + expect(mockAnchor.href).toBe('blob:mock-url'); + expect(mockAnchor.download).toBe('test.txt'); + }); + + it('should revoke the blob URL after download', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + svc.downloadBlob(blob, 'test.txt'); + expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('should revoke after clicking (correct order)', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + svc.downloadBlob(blob, 'test.txt'); + + const clickOrder = mockAnchor.click.mock.invocationCallOrder[0]; + const revokeOrder = revokeObjectURLSpy.mock.invocationCallOrder[0]; + expect(clickOrder).toBeLessThan(revokeOrder); + }); + }); +}); diff --git a/packages/core/src/lib/files/file-download.service.ts b/packages/core/src/lib/files/file-download.service.ts new file mode 100644 index 0000000..0936506 --- /dev/null +++ b/packages/core/src/lib/files/file-download.service.ts @@ -0,0 +1,61 @@ +import { Injectable, inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +/** + * Triggers file downloads programmatically. + * + * Uses the "hidden anchor + click" trick to initiate browser downloads + * from URLs or in-memory Blobs. Blob URLs are revoked after download + * to prevent memory leaks. + * + * @example + * ```typescript + * const dl = inject(FileDownloadService); + * + * // Download from URL + * dl.download('/api/files/123', 'report.pdf'); + * + * // Download in-memory blob + * const blob = new Blob(['hello'], { type: 'text/plain' }); + * dl.downloadBlob(blob, 'greeting.txt'); + * ``` + */ +@Injectable() +export class FileDownloadService { + private readonly doc = inject(DOCUMENT); + + /** + * Download a file from a URL. + * + * Creates a hidden `` element with the download attribute, + * clicks it, and removes it from the DOM. + * + * @param url — URL to download from. + * @param filename — Suggested filename for the download. + */ + download(url: string, filename: string): void { + const anchor = this.doc.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.style.display = 'none'; + + this.doc.body.appendChild(anchor); + anchor.click(); + this.doc.body.removeChild(anchor); + } + + /** + * Download an in-memory Blob as a file. + * + * Creates a temporary blob URL, triggers the download, then + * revokes the URL to free memory. + * + * @param blob — The Blob or File to download. + * @param filename — Suggested filename for the download. + */ + downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + this.download(url, filename); + URL.revokeObjectURL(url); + } +} diff --git a/packages/core/src/lib/files/file-export.service.spec.ts b/packages/core/src/lib/files/file-export.service.spec.ts new file mode 100644 index 0000000..4b57ac8 --- /dev/null +++ b/packages/core/src/lib/files/file-export.service.spec.ts @@ -0,0 +1,153 @@ +import { TestBed } from '@angular/core/testing'; +import { FileExportService } from './file-export.service'; +import { FileDownloadService } from './file-download.service'; +import { ExportConfig } from './file.types'; + +// --------------------------------------------------------------------------- +// Mock the three exporter modules +// --------------------------------------------------------------------------- +let mockCsvBlob: Blob; +let mockPdfBlob: Blob; +let mockExcelBlob: Blob; + +vi.mock('./exporters/csv-exporter', () => ({ + exportToCsv: (...args: any[]) => mockCsvBlob, +})); + +vi.mock('./exporters/pdf-exporter', () => ({ + exportToPdf: (...args: any[]) => Promise.resolve(mockPdfBlob), +})); + +vi.mock('./exporters/excel-exporter', () => ({ + exportToExcel: (...args: any[]) => Promise.resolve(mockExcelBlob), +})); + +interface TestRow { + name: string; + email: string; +} + +const TEST_DATA: TestRow[] = [ + { name: 'Alice', email: 'alice@test.com' }, + { name: 'Bob', email: 'bob@test.com' }, +]; + +function makeConfig(format: 'csv' | 'pdf' | 'xlsx'): ExportConfig { + return { + format, + filename: 'test-export', + columns: [ + { key: 'name', header: 'Name' }, + { key: 'email', header: 'Email' }, + ], + }; +} + +describe('FileExportService', () => { + let svc: FileExportService; + let downloadSpy: ReturnType; + + beforeEach(() => { + mockCsvBlob = new Blob(['csv-data'], { type: 'text/csv' }); + mockPdfBlob = new Blob(['pdf-data'], { type: 'application/pdf' }); + mockExcelBlob = new Blob(['excel-data'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + + downloadSpy = vi.fn(); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + FileExportService, + { provide: FileDownloadService, useValue: { downloadBlob: downloadSpy } }, + ], + }); + + svc = TestBed.inject(FileExportService); + }); + + // ------------------------------------------------------------------- + // export() — format routing + // ------------------------------------------------------------------- + it('should export CSV and trigger download with .csv extension', async () => { + await svc.export(TEST_DATA, makeConfig('csv')); + + expect(downloadSpy).toHaveBeenCalledOnce(); + expect(downloadSpy).toHaveBeenCalledWith(mockCsvBlob, 'test-export.csv'); + }); + + it('should export PDF and trigger download with .pdf extension', async () => { + await svc.export(TEST_DATA, makeConfig('pdf')); + + expect(downloadSpy).toHaveBeenCalledOnce(); + expect(downloadSpy).toHaveBeenCalledWith(mockPdfBlob, 'test-export.pdf'); + }); + + it('should export Excel and trigger download with .xlsx extension', async () => { + await svc.export(TEST_DATA, makeConfig('xlsx')); + + expect(downloadSpy).toHaveBeenCalledOnce(); + expect(downloadSpy).toHaveBeenCalledWith(mockExcelBlob, 'test-export.xlsx'); + }); + + // ------------------------------------------------------------------- + // generateBlob() — returns blob without download + // ------------------------------------------------------------------- + it('should generate CSV blob without triggering download', async () => { + const blob = await svc.generateBlob(TEST_DATA, makeConfig('csv')); + + expect(blob).toBe(mockCsvBlob); + expect(downloadSpy).not.toHaveBeenCalled(); + }); + + it('should generate PDF blob without triggering download', async () => { + const blob = await svc.generateBlob(TEST_DATA, makeConfig('pdf')); + + expect(blob).toBe(mockPdfBlob); + expect(downloadSpy).not.toHaveBeenCalled(); + }); + + it('should generate Excel blob without triggering download', async () => { + const blob = await svc.generateBlob(TEST_DATA, makeConfig('xlsx')); + + expect(blob).toBe(mockExcelBlob); + expect(downloadSpy).not.toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------- + // Error handling + // ------------------------------------------------------------------- + it('should throw for unsupported format', async () => { + const config = { ...makeConfig('csv'), format: 'xml' as any }; + + await expect(svc.export(TEST_DATA, config)).rejects.toThrow( + /Unsupported export format "xml"/, + ); + expect(downloadSpy).not.toHaveBeenCalled(); + }); + + it('should include supported formats in error message', async () => { + const config = { ...makeConfig('csv'), format: 'html' as any }; + + await expect(svc.generateBlob(TEST_DATA, config)).rejects.toThrow( + /Supported: csv, pdf, xlsx/, + ); + }); + + // ------------------------------------------------------------------- + // Filename extension + // ------------------------------------------------------------------- + it('should append .csv extension to filename', async () => { + await svc.export(TEST_DATA, { ...makeConfig('csv'), filename: 'report' }); + expect(downloadSpy.mock.calls[0][1]).toBe('report.csv'); + }); + + it('should append .pdf extension to filename', async () => { + await svc.export(TEST_DATA, { ...makeConfig('pdf'), filename: 'report' }); + expect(downloadSpy.mock.calls[0][1]).toBe('report.pdf'); + }); + + it('should append .xlsx extension to filename', async () => { + await svc.export(TEST_DATA, { ...makeConfig('xlsx'), filename: 'report' }); + expect(downloadSpy.mock.calls[0][1]).toBe('report.xlsx'); + }); +}); diff --git a/packages/core/src/lib/files/file-export.service.ts b/packages/core/src/lib/files/file-export.service.ts new file mode 100644 index 0000000..9f10397 --- /dev/null +++ b/packages/core/src/lib/files/file-export.service.ts @@ -0,0 +1,75 @@ +import { Injectable, inject } from '@angular/core'; +import { FileDownloadService } from './file-download.service'; +import { ExportConfig, ExportFormat } from './file.types'; +import { exportToCsv } from './exporters/csv-exporter'; +import { exportToPdf } from './exporters/pdf-exporter'; +import { exportToExcel } from './exporters/excel-exporter'; + +/** Maps export formats to file extensions. */ +const FORMAT_EXTENSIONS: Record = { + csv: '.csv', + pdf: '.pdf', + xlsx: '.xlsx', +}; + +/** + * Orchestrates tabular data export by selecting the correct exporter + * and delegating the download to `FileDownloadService`. + * + * @example + * ```typescript + * const exporter = inject(FileExportService); + * await exporter.export(users, { + * format: 'csv', + * filename: 'users-report', + * columns: [ + * { key: 'name', header: 'Name' }, + * { key: 'email', header: 'Email' }, + * ], + * }); + * ``` + */ +@Injectable() +export class FileExportService { + private readonly downloadService = inject(FileDownloadService); + + /** + * Export data to a file and trigger browser download. + * + * Selects the exporter based on `config.format`, generates the Blob, + * and delegates download to `FileDownloadService.downloadBlob()`. + * + * @param data — Array of data objects to export. + * @param config — Export configuration (format, filename, columns, etc.). + */ + async export(data: T[], config: ExportConfig): Promise { + const blob = await this.generateBlob(data, config); + const extension = FORMAT_EXTENSIONS[config.format]; + const filename = config.filename + extension; + this.downloadService.downloadBlob(blob, filename); + } + + /** + * Generate a Blob without triggering download. + * + * Useful when the caller needs the Blob for preview or further processing. + * + * @param data — Array of data objects to export. + * @param config — Export configuration. + * @returns The generated Blob. + */ + async generateBlob(data: T[], config: ExportConfig): Promise { + switch (config.format) { + case 'csv': + return exportToCsv(data, config); + case 'pdf': + return exportToPdf(data, config); + case 'xlsx': + return exportToExcel(data, config); + default: + throw new Error( + `FileExportService: Unsupported export format "${config.format}". Supported: csv, pdf, xlsx.`, + ); + } + } +} diff --git a/packages/core/src/lib/files/file-picker.service.spec.ts b/packages/core/src/lib/files/file-picker.service.spec.ts new file mode 100644 index 0000000..7c4b2ed --- /dev/null +++ b/packages/core/src/lib/files/file-picker.service.spec.ts @@ -0,0 +1,196 @@ +import { TestBed } from '@angular/core/testing'; +import { DOCUMENT } from '@angular/common'; +import { FilePickerService } from './file-picker.service'; + +describe('FilePickerService', () => { + let mockInput: { + type: string; + accept: string; + multiple: boolean; + style: { display: string }; + files: FileList | null; + click: ReturnType; + addEventListener: ReturnType; + removeEventListener: ReturnType; + parentNode: { removeChild: ReturnType } | null; + }; + let listeners: Record void>; + let mockDoc: { + createElement: ReturnType; + body: { appendChild: ReturnType }; + }; + + function setup() { + listeners = {}; + mockInput = { + type: '', + accept: '', + multiple: false, + style: { display: '' }, + files: null, + click: vi.fn(), + addEventListener: vi.fn((event: string, fn: (...args: unknown[]) => void) => { + listeners[event] = fn; + }), + removeEventListener: vi.fn(), + parentNode: { removeChild: vi.fn() }, + }; + + mockDoc = { + createElement: vi.fn().mockReturnValue(mockInput), + body: { appendChild: vi.fn() }, + }; + + TestBed.configureTestingModule({ + providers: [ + FilePickerService, + { provide: DOCUMENT, useValue: mockDoc }, + ], + }); + return TestBed.inject(FilePickerService); + } + + function createFileList(files: File[]): FileList { + const list = { + length: files.length, + item: (i: number) => files[i] ?? null, + [Symbol.iterator]: function* () { yield* files; }, + } as unknown as FileList; + for (let i = 0; i < files.length; i++) { + (list as any)[i] = files[i]; + } + return list; + } + + // ------------------------------------------------------------------- + // pickFile + // ------------------------------------------------------------------- + it('should create a hidden file input and click it', async () => { + const svc = setup(); + const promise = svc.pickFile(); + + expect(mockDoc.createElement).toHaveBeenCalledWith('input'); + expect(mockInput.type).toBe('file'); + expect(mockInput.style.display).toBe('none'); + expect(mockDoc.body.appendChild).toHaveBeenCalledWith(mockInput); + expect(mockInput.click).toHaveBeenCalled(); + + // Simulate user selecting a file + const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); + mockInput.files = createFileList([file]); + listeners['change'](); + + const result = await promise; + expect(result).toEqual([file]); + }); + + it('should set accept attribute when provided', async () => { + const svc = setup(); + const promise = svc.pickFile('image/*'); + mockInput.files = createFileList([]); + listeners['change'](); + await promise; + + expect(mockInput.accept).toBe('image/*'); + }); + + it('should set multiple attribute when true', async () => { + const svc = setup(); + const promise = svc.pickFile(undefined, true); + mockInput.files = createFileList([]); + listeners['change'](); + await promise; + + expect(mockInput.multiple).toBe(true); + }); + + it('should not set multiple when false or undefined', async () => { + const svc = setup(); + const promise = svc.pickFile(); + mockInput.files = createFileList([]); + listeners['change'](); + await promise; + + expect(mockInput.multiple).toBe(false); + }); + + it('should return multiple files when multiple is true', async () => { + const svc = setup(); + const promise = svc.pickFile(undefined, true); + + const f1 = new File(['a'], 'a.txt', { type: 'text/plain' }); + const f2 = new File(['b'], 'b.txt', { type: 'text/plain' }); + mockInput.files = createFileList([f1, f2]); + listeners['change'](); + + const result = await promise; + expect(result).toEqual([f1, f2]); + }); + + it('should return empty array when cancelled', async () => { + const svc = setup(); + const promise = svc.pickFile(); + listeners['cancel'](); + + const result = await promise; + expect(result).toEqual([]); + }); + + it('should return empty array when files is null on change', async () => { + const svc = setup(); + const promise = svc.pickFile(); + mockInput.files = null; + listeners['change'](); + + const result = await promise; + expect(result).toEqual([]); + }); + + it('should cleanup input after selection', async () => { + const svc = setup(); + const promise = svc.pickFile(); + mockInput.files = createFileList([]); + listeners['change'](); + await promise; + + expect(mockInput.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + expect(mockInput.removeEventListener).toHaveBeenCalledWith('cancel', expect.any(Function)); + expect(mockInput.parentNode!.removeChild).toHaveBeenCalledWith(mockInput); + }); + + it('should cleanup input after cancel', async () => { + const svc = setup(); + const promise = svc.pickFile(); + listeners['cancel'](); + await promise; + + expect(mockInput.removeEventListener).toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------- + // pickImage + // ------------------------------------------------------------------- + it('should call pickFile with image/* and single mode', async () => { + const svc = setup(); + const promise = svc.pickImage(); + + expect(mockInput.accept).toBe('image/*'); + expect(mockInput.multiple).toBe(false); + + const img = new File(['pixels'], 'photo.png', { type: 'image/png' }); + mockInput.files = createFileList([img]); + listeners['change'](); + + const result = await promise; + expect(result).toEqual(img); + }); + + it('should return null from pickImage when cancelled', async () => { + const svc = setup(); + const promise = svc.pickImage(); + listeners['cancel'](); + + const result = await promise; + expect(result).toBeNull(); + }); +}); diff --git a/packages/core/src/lib/files/file-picker.service.ts b/packages/core/src/lib/files/file-picker.service.ts new file mode 100644 index 0000000..30892e8 --- /dev/null +++ b/packages/core/src/lib/files/file-picker.service.ts @@ -0,0 +1,70 @@ +import { Injectable, inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +/** + * Opens the browser's native file picker programmatically. + * + * Creates a hidden `` element, triggers a click, + * and resolves with the selected files. The input is removed after use. + * + * @example + * ```typescript + * const files = await picker.pickFile('application/pdf,.pdf', true); + * const image = await picker.pickImage(); + * ``` + */ +@Injectable() +export class FilePickerService { + private readonly doc = inject(DOCUMENT); + + /** + * Open the native file picker dialog. + * + * @param accept — MIME types or extensions (e.g. `'image/*'`, `'.pdf,.docx'`) + * @param multiple — Allow selecting multiple files. Default: `false`. + * @returns Selected files, or empty array if cancelled. + */ + pickFile(accept?: string, multiple?: boolean): Promise { + return new Promise(resolve => { + const input = this.doc.createElement('input') as HTMLInputElement; + input.type = 'file'; + if (accept) input.accept = accept; + if (multiple) input.multiple = true; + input.style.display = 'none'; + + const cleanup = () => { + input.removeEventListener('change', onChange); + input.removeEventListener('cancel', onCancel); + if (input.parentNode) input.parentNode.removeChild(input); + }; + + const onChange = () => { + const files = input.files ? Array.from(input.files) : []; + cleanup(); + resolve(files); + }; + + const onCancel = () => { + cleanup(); + resolve([]); + }; + + input.addEventListener('change', onChange); + input.addEventListener('cancel', onCancel); + + this.doc.body.appendChild(input); + input.click(); + }); + } + + /** + * Open the native file picker filtered to images only. + * + * Shorthand for `pickFile('image/*', false)`. + * @returns The selected image, or `null` if cancelled. + */ + async pickImage(): Promise { + const files = await this.pickFile('image/*', false); + return files[0] ?? null; + } +} diff --git a/packages/core/src/lib/files/file-preview.service.spec.ts b/packages/core/src/lib/files/file-preview.service.spec.ts new file mode 100644 index 0000000..efcdf07 --- /dev/null +++ b/packages/core/src/lib/files/file-preview.service.spec.ts @@ -0,0 +1,176 @@ +import { TestBed } from '@angular/core/testing'; +import { DOCUMENT } from '@angular/common'; +import { FilePreviewService } from './file-preview.service'; +import { FILES_CONFIG } from './file.types'; + +describe('FilePreviewService', () => { + let svc: FilePreviewService; + let mockWindow: { open: ReturnType }; + let mockDoc: { defaultView: any }; + let createObjectURLSpy: ReturnType; + let revokeObjectURLSpy: ReturnType; + let urlCounter: number; + + function setup(config?: object) { + TestBed.resetTestingModule(); + + mockWindow = { open: vi.fn() }; + mockDoc = { defaultView: mockWindow }; + + TestBed.configureTestingModule({ + providers: [ + FilePreviewService, + { provide: DOCUMENT, useValue: mockDoc }, + ...(config ? [{ provide: FILES_CONFIG, useValue: config }] : []), + ], + }); + + svc = TestBed.inject(FilePreviewService); + } + + beforeEach(() => { + urlCounter = 0; + createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => { + return `blob:mock-url-${++urlCounter}`; + }); + revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => { /* noop */ }); + }); + + afterEach(() => { + createObjectURLSpy.mockRestore(); + revokeObjectURLSpy.mockRestore(); + }); + + // ------------------------------------------------------------------- + // createBlobUrl() + // ------------------------------------------------------------------- + describe('createBlobUrl()', () => { + it('should create a blob URL from a blob', () => { + setup(); + const blob = new Blob(['test'], { type: 'text/plain' }); + const url = svc.createBlobUrl(blob); + + expect(createObjectURLSpy).toHaveBeenCalledWith(blob); + expect(url).toBe('blob:mock-url-1'); + }); + + it('should track the created URL', () => { + setup(); + const blob = new Blob(['test'], { type: 'text/plain' }); + svc.createBlobUrl(blob); + + expect(svc.activeUrlCount).toBe(1); + }); + }); + + // ------------------------------------------------------------------- + // revokeBlobUrl() + // ------------------------------------------------------------------- + describe('revokeBlobUrl()', () => { + it('should revoke a blob URL', () => { + setup(); + const blob = new Blob(['test'], { type: 'text/plain' }); + const url = svc.createBlobUrl(blob); + svc.revokeBlobUrl(url); + + expect(revokeObjectURLSpy).toHaveBeenCalledWith(url); + }); + + it('should remove the URL from tracking', () => { + setup(); + const blob = new Blob(['test'], { type: 'text/plain' }); + const url = svc.createBlobUrl(blob); + svc.revokeBlobUrl(url); + + expect(svc.activeUrlCount).toBe(0); + }); + }); + + // ------------------------------------------------------------------- + // revokeAll() + // ------------------------------------------------------------------- + describe('revokeAll()', () => { + it('should revoke all tracked URLs', () => { + setup(); + const b1 = new Blob(['a'], { type: 'text/plain' }); + const b2 = new Blob(['b'], { type: 'text/plain' }); + svc.createBlobUrl(b1); + svc.createBlobUrl(b2); + + svc.revokeAll(); + + expect(revokeObjectURLSpy).toHaveBeenCalledTimes(2); + expect(svc.activeUrlCount).toBe(0); + }); + }); + + // ------------------------------------------------------------------- + // previewInNewTab() + // ------------------------------------------------------------------- + describe('previewInNewTab()', () => { + it('should create a blob URL and open it in a new tab', () => { + setup(); + const blob = new Blob(['pdf content'], { type: 'application/pdf' }); + const url = svc.previewInNewTab(blob); + + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(mockWindow.open).toHaveBeenCalledWith(url, '_blank'); + expect(url).toBe('blob:mock-url-1'); + }); + + it('should track the URL for cleanup', () => { + setup(); + const blob = new Blob(['content'], { type: 'text/plain' }); + svc.previewInNewTab(blob); + + expect(svc.activeUrlCount).toBe(1); + }); + + it('should create a new blob with override MIME type when provided', () => { + setup(); + const blob = new Blob(['content'], { type: 'application/octet-stream' }); + svc.previewInNewTab(blob, 'application/pdf'); + + // createObjectURL is called with a new Blob (with overridden type) + const calledBlob = createObjectURLSpy.mock.calls[0][0] as Blob; + expect(calledBlob.type).toBe('application/pdf'); + }); + + it('should return null when enablePreview is false', () => { + setup({ + maxFileSizeBytes: 10_000_000, + allowedMimeTypes: [], + maxConcurrentUploads: 5, + enablePreview: false, + }); + const blob = new Blob(['content'], { type: 'text/plain' }); + const result = svc.previewInNewTab(blob); + + expect(result).toBeNull(); + expect(mockWindow.open).not.toHaveBeenCalled(); + }); + + it('should work when enablePreview is true', () => { + setup({ + maxFileSizeBytes: 10_000_000, + allowedMimeTypes: [], + maxConcurrentUploads: 5, + enablePreview: true, + }); + const blob = new Blob(['content'], { type: 'text/plain' }); + const url = svc.previewInNewTab(blob); + + expect(url).not.toBeNull(); + expect(mockWindow.open).toHaveBeenCalled(); + }); + + it('should work without FILES_CONFIG (preview enabled by default)', () => { + setup(); // no config + const blob = new Blob(['content'], { type: 'text/plain' }); + const url = svc.previewInNewTab(blob); + + expect(url).not.toBeNull(); + expect(mockWindow.open).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/lib/files/file-preview.service.ts b/packages/core/src/lib/files/file-preview.service.ts new file mode 100644 index 0000000..8a1d4cf --- /dev/null +++ b/packages/core/src/lib/files/file-preview.service.ts @@ -0,0 +1,93 @@ +import { Injectable, inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { FILES_CONFIG } from './file.types'; + +/** + * Manages file previews and blob URL lifecycle. + * + * Creates temporary blob URLs for displaying file content and opens + * previews in new browser tabs. Tracks active blob URLs and provides + * cleanup to prevent memory leaks. + * + * @example + * ```typescript + * const preview = inject(FilePreviewService); + * + * // Preview a blob in a new tab + * preview.previewInNewTab(blob, 'application/pdf'); + * + * // Manual blob URL management + * const url = preview.createBlobUrl(blob); + * // ... use url in an or