I'm in the middle of migrating an older project from Angular 7 to Angular 12. My team made a decision to start from scratch and build the application up using good patterns and practices this time (the original project had to be thrown together rather quickly to meet a business deadline; so it goes). This week I arrived at a piece of code intended to print a receipt. The receipt itself is an isolated component that is typically (but not always) displayed in a modal (we use ng-bootstrap). In the old version we have a rather sloppy (but effective) piece of code that opens a new window and writes the HTML contents of the receipt into the new window, then immediately invokes the print function. It does work, but man is it unseemly. At first I tried to just migrate that code over, but the newer version of TypeScript didn't like some things about it (and I didn't spend much time trying to figure out what it didn't like). For posterity, here's the old code.
print() {
let printContents, popupWin;
printContents = document.getElementById('print-section').innerHTML
printContents = `<div div class="text-center mb-4 mt-4"><img src="https://logo.url" class="receipt-logo"></div>${printContents}`;
popupWin = window.open('/', '_blank', 'top=0,left=0,height=100%,width=auto');
popupWin.document.open();
popupWin.document.write(`
<html>
<head>
<title>Receipt</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css" integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB" crossorigin="anonymous">
<style>
@page {
size: auto; /* auto is the initial value */
margin: 5%;
}
body {
padding-top: 50px;
}
.btn {
display: none;
}
.text-primary {
color: #333333;
}
.receipt-logo {
width: 200px;
}
</style>
</head>
<body onload="window.print();setTimeout(function() { window.close(); }, 500); // Timout is Safari hack. Safari dosn't wait for print to close window">${printContents}</body>
</html>
);
popupWin.document.close();
}
I want to stress once more that this absolutely does work, but... well, just look at it. It's unpleasant. Since we're taking the time to rewrite the entire application I wanted a better way to do this, but Angular sure didn't make it easy.
- The component's contents must be printable while the component is hosted in a modal and while it is hosted outside a modal
- I don't want to reload the entire contents of the receipt just so I can print it, but I'm OK with using data that's in state (which is how the application is constructed) to generate new HTML
- I want to be able to control what is printed on the receipt, and potentially have that be different than what is displayed in the UI
template: `<router-outlet></router-outlet><app-footer></app-footer>`
import { Directive, ViewContainerRef } from '@angular/core';
@Directive({
selector: '[appPrint]'
})
export class PrintDirective {
constructor(public viewContainerRef: ViewContainerRef) { }
}
import { Injectable, Type } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { PrintableComponent } from '../../../data/models';
@Injectable({
providedIn: 'root'
})
export class PrintStateService {
public printNotifier: BehaviorSubject<string | null> = new BehaviorSubject<string | null>(null);
public type?: Type<PrintableComponent>;
public clear(): void {
this.type = undefined;
this.printNotifier.next(null);
}
public set(type: Type<PrintableComponent>): void {
this.type = type;
this.printNotifier.next(uuidv4());
}
}
import { AfterViewInit, Component, ComponentFactoryResolver, OnDestroy, ViewChild, ViewEncapsulation } from '@angular/core'
import { Subject } from 'rxjs'
import { takeUntil } from 'rxjs/operators';
import { PrintStateService } from '../../../core';
import { PrintDirective } from '../../directives/print.directive';
@Component({
selector: 'app-print',
template: '<ng-template atlasPrint><ng-template>',
styleUrls: ['./print-component.scss'],
encapsulation: ViewEncapsulation.None
})
export class PrintComponent implements AfterViewInit, OnDestroy {
private takeSubscriptionsUntil: Subject<void> = new Subject();
@ViewChild(PrintDirective, {static: true}) printHost!: PrintDirective;
constructor (
private componentFactoryResovler: ComponentFactoryResolver,
private printStateService: PrintStateService
) { }
public ngAfterViewInit(): void {
this.printStateService.printNotifier.pipe(takeUntil(this.takeSubscriptionsUntil))
.subscribe((notificationId: string | null) => {
if (!!notificationId) {
this.loadComponent();
} else {
this.printHost.viewContainerRef.clear();
}
})
}
public ngOnDestroy(): void {
this.takeSubscriptionsUntil.next();
this.takeSubscriptionsUntil.complete();
}
private loadComponent(): void {
if (!this.printStateService.type( {
return;
}
const viewContainerRef = this.printHost.viewContainerRef;
viewContainerRef.clear();
const factory = this.componentFactoryResolver.resolveComponentFactory(this.printStateService.type);
const componentRef = viewContainerRef.createComponent(factory);
componentRef.instance.isPrinted = true;
// this is wrapped in a setTimeout because not doing that causes the data to not be loaded into the injected component
// so basically wrapping it like this gives the lifecycle the necessary order to populate the injected component so there's
// actually something to display in the print preview window
setTimeout(() => window.print(), 1);
}
}
@media screen {
app-print {
display: none !important;
}
}
@media print {
.modal, .modal-backdrop, .app-wrapper, .app-footer, button {
display: none !important;
}
}
template: `<router-outlet></router-outlet><app-print></app-print><app-footer></app-footer>`
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
import { Subject } from 'rxjs'
import { takeUntil } from 'rxjs/operators';
import { OrderStatuses, OrderTypes, PaymentTypes } from '../../../data/enums';
import { LoadingStateService, PrintStateService, ReceiptStateService } from '../../../core';
import { PrintableComponent, Receipt } from '../../data/models';
@Component({
selector: 'app-receipt',
templateUrl: './receipt.component.html',
styleUrls: ['./receipt-component.scss']
})
export class ReceiptComponent implements OnInit, OnDestroy, PrintableComponent {
@Input() isModal: boolean = false;
@Input() isPrinted: boolean = false;
private takeSubscriptionsUntil: Subject<void> = new Subject();
public receipt: Receipt | null | undefined;
public isLoading = false;
public OrderStatuses: typeof OrderStatuses = OrderStatuses;
public OrderTypes: typeof OrderTypes = OrderTypes;
public PaymentTypes: typeof PaymentTypes = PaymentTypes;
constructor (
private printStateService: PrintStateService,
private receiptStateService: ReceiptStateService
) { }
public ngOnInit(): void {
this.receiptStateService.stateChanged.pipe(takeUntil(this.takeSubscriptionsUntil))
.subscribe((state: StoreState) => state?.receipt);
}
public ngOnDestroy(): void {
this.takeSubscriptionsUntil.next();
this.takeSubscriptionsUntil.complete();
}
public print(): void {
this.printStateService.set(ReceiptComponent);
}
}
export interface PrintableComponent {
isPrinted: boolean;
}