From a283c1c320d505412592c9308b0a02f6f777b9e6 Mon Sep 17 00:00:00 2001 From: Mattia Paletti <59086526+mpaletti@users.noreply.github.com> Date: Thu, 11 Sep 2025 19:59:11 +0200 Subject: [PATCH] Enhancement: Add print button (#10626) --------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- .../document-detail.component.html | 4 + .../document-detail.component.spec.ts | 147 ++++++++++++++++++ .../document-detail.component.ts | 42 +++++ src-ui/src/main.ts | 2 + 4 files changed, 195 insertions(+) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index c926c82d9..42b307e58 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -54,6 +54,10 @@  Reprocess + + diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index ed0d2a125..97dae19b7 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -1415,4 +1415,151 @@ describe('DocumentDetailComponent', () => { .flush('fail', { status: 500, statusText: 'Server Error' }) expect(component.previewText).toContain('An error occurred loading content') }) + + it('should print document successfully', fakeAsync(() => { + initNormally() + + const appendChildSpy = jest + .spyOn(document.body, 'appendChild') + .mockImplementation((node: Node) => node) + const removeChildSpy = jest + .spyOn(document.body, 'removeChild') + .mockImplementation((node: Node) => node) + const createObjectURLSpy = jest + .spyOn(URL, 'createObjectURL') + .mockReturnValue('blob:mock-url') + const revokeObjectURLSpy = jest + .spyOn(URL, 'revokeObjectURL') + .mockImplementation(() => {}) + + const mockContentWindow = { + focus: jest.fn(), + print: jest.fn(), + onafterprint: null, + } + + const mockIframe = { + style: {}, + src: '', + onload: null, + contentWindow: mockContentWindow, + } + + const createElementSpy = jest + .spyOn(document, 'createElement') + .mockReturnValue(mockIframe as any) + + const blob = new Blob(['test'], { type: 'application/pdf' }) + component.printDocument() + + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/${doc.id}/download/` + ) + req.flush(blob) + + tick() + + expect(createElementSpy).toHaveBeenCalledWith('iframe') + expect(appendChildSpy).toHaveBeenCalledWith(mockIframe) + expect(createObjectURLSpy).toHaveBeenCalledWith(blob) + + if (mockIframe.onload) { + mockIframe.onload({} as any) + } + + expect(mockContentWindow.focus).toHaveBeenCalled() + expect(mockContentWindow.print).toHaveBeenCalled() + + if (mockIframe.onload) { + mockIframe.onload(new Event('load')) + } + + if (mockContentWindow.onafterprint) { + mockContentWindow.onafterprint(new Event('afterprint')) + } + + expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) + expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') + + createElementSpy.mockRestore() + appendChildSpy.mockRestore() + removeChildSpy.mockRestore() + createObjectURLSpy.mockRestore() + revokeObjectURLSpy.mockRestore() + })) + + it('should show error toast if print document fails', () => { + initNormally() + const toastSpy = jest.spyOn(toastService, 'showError') + component.printDocument() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/${doc.id}/download/` + ) + req.error(new ErrorEvent('failed')) + expect(toastSpy).toHaveBeenCalledWith( + 'Error loading document for printing.' + ) + }) + + it('should show error toast if printing throws inside iframe', fakeAsync(() => { + initNormally() + + const appendChildSpy = jest + .spyOn(document.body, 'appendChild') + .mockImplementation((node: Node) => node) + const removeChildSpy = jest + .spyOn(document.body, 'removeChild') + .mockImplementation((node: Node) => node) + const createObjectURLSpy = jest + .spyOn(URL, 'createObjectURL') + .mockReturnValue('blob:mock-url') + const revokeObjectURLSpy = jest + .spyOn(URL, 'revokeObjectURL') + .mockImplementation(() => {}) + + const toastSpy = jest.spyOn(toastService, 'showError') + + const mockContentWindow = { + focus: jest.fn().mockImplementation(() => { + throw new Error('focus failed') + }), + print: jest.fn(), + onafterprint: null, + } + + const mockIframe: any = { + style: {}, + src: '', + onload: null, + contentWindow: mockContentWindow, + } + + const createElementSpy = jest + .spyOn(document, 'createElement') + .mockReturnValue(mockIframe as any) + + const blob = new Blob(['test'], { type: 'application/pdf' }) + component.printDocument() + + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/${doc.id}/download/` + ) + req.flush(blob) + + tick() + + if (mockIframe.onload) { + mockIframe.onload(new Event('load')) + } + + expect(toastSpy).toHaveBeenCalled() + expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) + expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') + + createElementSpy.mockRestore() + appendChildSpy.mockRestore() + removeChildSpy.mockRestore() + createObjectURLSpy.mockRestore() + revokeObjectURLSpy.mockRestore() + })) }) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index d139550c0..08c9a637c 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -291,6 +291,10 @@ export class DocumentDetailComponent return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER) } + get isMobile(): boolean { + return this.deviceDetectorService.isMobile() + } + get archiveContentRenderType(): ContentRenderType { return this.document?.archived_file_name ? this.getRenderType('application/pdf') @@ -1419,6 +1423,44 @@ export class DocumentDetailComponent }) } + printDocument() { + const printUrl = this.documentsService.getDownloadUrl( + this.document.id, + false + ) + this.http + .get(printUrl, { responseType: 'blob' }) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe({ + next: (blob) => { + const blobUrl = URL.createObjectURL(blob) + const iframe = document.createElement('iframe') + iframe.style.display = 'none' + iframe.src = blobUrl + document.body.appendChild(iframe) + iframe.onload = () => { + try { + iframe.contentWindow.focus() + iframe.contentWindow.print() + iframe.contentWindow.onafterprint = () => { + document.body.removeChild(iframe) + URL.revokeObjectURL(blobUrl) + } + } catch (err) { + this.toastService.showError($localize`Print failed.`, err) + document.body.removeChild(iframe) + URL.revokeObjectURL(blobUrl) + } + } + }, + error: () => { + this.toastService.showError( + $localize`Error loading document for printing.` + ) + }, + }) + } + public openShareLinks() { const modal = this.modalService.open(ShareLinksDialogComponent) modal.componentInstance.documentId = this.document.id diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts index 029ec72ac..5ed4fe373 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -110,6 +110,7 @@ import { playFill, plus, plusCircle, + printer, questionCircle, scissors, search, @@ -319,6 +320,7 @@ const icons = { playFill, plus, plusCircle, + printer, questionCircle, scissors, search,