Skip to content

Commit 9e879e5

Browse files
WEB-976: fix report downloads (#3727)
Co-authored-by: Víctor Romero <46640258+IOhacker@users.noreply.github.com>
1 parent 5ec26dd commit 9e879e5

4 files changed

Lines changed: 106 additions & 22 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Copyright since 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
import { downloadBlob } from './file-download.utils';
10+
11+
describe('downloadBlob', () => {
12+
const objectUrl = 'blob:report-download';
13+
let createObjectURLSpy: jest.SpyInstance;
14+
let revokeObjectURLSpy: jest.SpyInstance;
15+
16+
beforeEach(() => {
17+
jest.useFakeTimers();
18+
Object.defineProperty(URL, 'createObjectURL', {
19+
configurable: true,
20+
value: jest.fn()
21+
});
22+
Object.defineProperty(URL, 'revokeObjectURL', {
23+
configurable: true,
24+
value: jest.fn()
25+
});
26+
createObjectURLSpy = jest.spyOn(URL, 'createObjectURL').mockReturnValue(objectUrl);
27+
revokeObjectURLSpy = jest.spyOn(URL, 'revokeObjectURL').mockImplementation();
28+
});
29+
30+
afterEach(() => {
31+
jest.runOnlyPendingTimers();
32+
jest.useRealTimers();
33+
createObjectURLSpy.mockRestore();
34+
revokeObjectURLSpy.mockRestore();
35+
});
36+
37+
it('creates and clicks a temporary download link', () => {
38+
const clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation();
39+
const blob = new Blob(['report'], { type: 'text/plain' });
40+
41+
downloadBlob(blob, 'client-report.xlsx');
42+
43+
const link = document.body.querySelector('a[download="client-report.xlsx"]') as HTMLAnchorElement;
44+
expect(createObjectURLSpy).toHaveBeenCalledWith(blob);
45+
expect(link).toBeTruthy();
46+
expect(link.href).toBe(objectUrl);
47+
expect(link.style.display).toBe('none');
48+
expect(clickSpy).toHaveBeenCalledTimes(1);
49+
50+
clickSpy.mockRestore();
51+
});
52+
53+
it('removes the temporary link and revokes the object URL', () => {
54+
const clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation();
55+
56+
downloadBlob(new Blob(['report']), 'client-report.xlsx');
57+
58+
expect(document.body.querySelector('a[download="client-report.xlsx"]')).toBeTruthy();
59+
60+
jest.runOnlyPendingTimers();
61+
62+
expect(document.body.querySelector('a[download="client-report.xlsx"]')).toBeNull();
63+
expect(revokeObjectURLSpy).toHaveBeenCalledWith(objectUrl);
64+
65+
clickSpy.mockRestore();
66+
});
67+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Copyright since 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
/**
10+
* Triggers a browser download for generated file content.
11+
*
12+
* @param {Blob} blob File content to download.
13+
* @param {string} fileName Name to use for the downloaded file.
14+
*/
15+
export function downloadBlob(blob: Blob, fileName: string): void {
16+
const url = URL.createObjectURL(blob);
17+
const link = document.createElement('a');
18+
link.href = url;
19+
link.download = fileName;
20+
link.style.display = 'none';
21+
22+
document.body.appendChild(link);
23+
link.click();
24+
25+
setTimeout(() => {
26+
if (link.parentNode) {
27+
link.parentNode.removeChild(link);
28+
}
29+
URL.revokeObjectURL(url);
30+
}, 0);
31+
}

src/app/reports/run-report/run-report.component.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { SelectOption } from '../common-models/select-option.model';
2222
import { Dates } from 'app/core/utils/dates';
2323
import { GlobalConfiguration } from 'app/system/configurations/global-configurations-tab/configuration.model';
2424
import { sanitizeCsvValue } from 'app/core/utils/csv.utils';
25+
import { downloadBlob } from 'app/core/utils/file-download.utils';
2526

2627
import * as ExcelJS from 'exceljs';
2728
import { AlertService } from 'app/core/alert/alert.service';
@@ -563,16 +564,6 @@ export class RunReportComponent implements OnInit {
563564
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
564565
});
565566

566-
// Native download logic (no FileSaver)
567-
const url = URL.createObjectURL(blob);
568-
const a = document.createElement('a');
569-
a.href = url;
570-
a.download = fileName;
571-
document.body.appendChild(a);
572-
a.click();
573-
setTimeout(() => {
574-
document.body.removeChild(a);
575-
URL.revokeObjectURL(url);
576-
}, 0);
567+
downloadBlob(blob, fileName);
577568
}
578569
}

src/app/reports/run-report/table-and-sms/table-and-sms.component.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.componen
3535
import { environment } from '../../../../environments/environment';
3636
import { ProgressBarService } from 'app/core/progress-bar/progress-bar.service';
3737
import { sanitizeCsvValue } from 'app/core/utils/csv.utils';
38+
import { downloadBlob } from 'app/core/utils/file-download.utils';
3839

3940
import * as ExcelJS from 'exceljs';
4041
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -184,13 +185,13 @@ export class TableAndSmsComponent implements OnChanges {
184185
};
185186
const exportDialogRef = this.dialog.open(FormDialogComponent, { data });
186187
exportDialogRef.afterClosed().subscribe((response: { data: any }) => {
187-
if (response.data) {
188+
if (response?.data) {
188189
this.downloadCSV(response.data.value.fileName, response.data.value.delimiter);
189190
}
190191
});
191192
}
192193

193-
exportToXLS(): void {
194+
async exportToXLS(): Promise<void> {
194195
const fileName = `${this.dataObject.report.name}.xlsx`;
195196
// Sanitize all values to prevent formula injection in spreadsheet applications
196197
const data = this.csvData.map((object: any) => {
@@ -212,15 +213,9 @@ export class TableAndSmsComponent implements OnChanges {
212213
worksheet.addRow(this.displayedColumns.map((col) => rowObj[col]));
213214
});
214215

215-
workbook.xlsx.writeBuffer().then((buffer: any) => {
216-
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
217-
const url = URL.createObjectURL(blob);
218-
const a = document.createElement('a');
219-
a.href = url;
220-
a.download = 'filename.xlsx';
221-
a.click();
222-
URL.revokeObjectURL(url);
223-
});
216+
const buffer = await workbook.xlsx.writeBuffer();
217+
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
218+
downloadBlob(blob, fileName);
224219
}
225220

226221
/**

0 commit comments

Comments
 (0)