Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input type="file">`
- `FileUploadService` — HTTP POST upload with real-time progress tracking via `Observable<UploadProgress>`
- `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
Expand Down
3 changes: 3 additions & 0 deletions packages/core/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export default [
'@angular/common',
'@angular/router',
'rxjs',
'jspdf',
'jspdf-autotable',
'exceljs',
],
},
],
Expand Down
16 changes: 14 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fireflyframework/core",
"version": "0.12.0",
"version": "0.13.0",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
Expand All @@ -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
}
}
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
160 changes: 160 additions & 0 deletions packages/core/src/lib/files/exporters/csv-exporter.spec.ts
Original file line number Diff line number Diff line change
@@ -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<TestRow>['columns'] = [
{ key: 'name', header: 'Name' },
{ key: 'email', header: 'Email' },
{ key: 'age', header: 'Age' },
];

function blobToText(blob: Blob): Promise<string> {
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<TestRow> = {
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<ArrayBuffer>((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<TestRow> = { ...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<TestRow> = { ...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<Partial> = {
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);
});
});
70 changes: 70 additions & 0 deletions packages/core/src/lib/files/exporters/csv-exporter.ts
Original file line number Diff line number Diff line change
@@ -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<T>(data: T[], config: ExportConfig<T>): 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' });
}
Loading