Elegante Navigation Guards in Angular - Teil 2: Eigene Modals und testbare Provider
In Teil 1 dieser Serie haben wir einen wiederverwendbaren funktionalen Guard gebaut, um die Routennavigation zu blockieren, sobald sich eine Formular-Komponente in einem geänderten (“dirty”) Zustand befand. Dabei haben wir uns auf das native confirm()-Fenster des Browsers verlassen.
Native Bestätigungsdialoge können jedoch nicht an moderne Designsysteme angepasst werden und erschweren automatisierte Tests.
In diesem zweiten Teil lösen wir diese Probleme, indem wir unsere Bestätigungslogik mithilfe von Dependency Injection (DI) Providern und modernen Standalone-Provider-Hilfsfunktionen entkoppeln.
Schritt 1: Einführung des Injection Tokens
Anstatt confirm() direkt im Guard aufzurufen, deklarieren wir eine Abstraktion für den Warnungsdialog: ein NavigationConfirm-Interface und ein InjectionToken.
Aktualisieren wir pending-changes.ts mit diesen Definitionen:
import { InjectionToken, makeEnvironmentProviders, EnvironmentProviders, Type } from '@angular/core';
import { Observable } from 'rxjs';
export interface PendingChanges {
isDirty(): boolean | Observable<boolean> | Promise<boolean>;
}
// 1. Der Vertrag für die Anzeige des Bestätigungsdialogs
export interface NavigationConfirm {
confirm(): boolean | Observable<boolean> | Promise<boolean>;
}
// 2. Das Injection Token mit einem Standard-Browser-Confirm-Fallback
export const NAVIGATION_CONFIRM = new InjectionToken<NavigationConfirm>(
'NAVIGATION_CONFIRM',
{
providedIn: 'root',
factory: () => ({
confirm: () => confirm('Sie haben ungespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?')
})
}
);
Schritt 2: Schreiben der Provider-Hilfsfunktion
Moderne Angular-Versionen nutzen dedizierte Hilfsfunktionen (wie provideRouter() oder provideHttpClient()), um Konfigurationen zu registrieren.
Wir können unsere eigene Standalone-Provider-Funktion mithilfe von Angulars makeEnvironmentProviders schreiben. Fügen Sie diesen Helfer am Ende von pending-changes.ts hinzu:
// 3. Moderne Provider-Hilfsfunktion unter Verwendung von makeEnvironmentProviders
export function provideNavigationConfirm(
confirmService: Type<NavigationConfirm>
): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: NAVIGATION_CONFIRM,
useClass: confirmService
}
]);
}
Die Verwendung von makeEnvironmentProviders beschränkt diesen Provider auf Anwendungs- und Routen-Injectoren. Dies verhindert, dass er fälschlicherweise in einem komponentenbezogenen Array deklariert wird, was zu Hierarchieproblemen führen könnte.
Schritt 3: Refaktorierung des Guards zur DI-Nutzung
Jetzt aktualisieren wir unseren funktionalen Guard so, dass er das NAVIGATION_CONFIRM-Token injiziert:
import { inject } from '@angular/core';
import { CanDeactivateFn } from '@angular/router';
export const pendingChangesGuard: CanDeactivateFn<PendingChanges> = (component) => {
if (!component || !component.isDirty()) {
return true;
}
// Injizieren des abstrakten Bestätigungsdienstes
const confirmService = inject(NAVIGATION_CONFIRM);
// Rückgabe des Ergebnisses des abstrakten Aufrufs
return confirmService.confirm();
};
Schritt 4: Überschreiben des nativen Confirms mit einem UI-Modal
Implementieren wir nun einen sauberen Dialogdienst unter Verwendung von Angular Materials MatDialog, der ein Observable<boolean> zurückgibt, sobald der Benutzer eine Option auswählt:
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { NavigationConfirm } from './pending-changes';
import { ConfirmDialogComponent } from './confirm-dialog.component';
import { Observable } from 'rxjs';
@Injectable()
export class CustomNavigationConfirmService implements NavigationConfirm {
constructor(private dialog: MatDialog) {}
confirm(): Observable<boolean> {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
width: '400px',
disableClose: true
});
return dialogRef.afterClosed();
}
}
Binden Sie nun die benutzerdefinierte Implementierung in app.config.ts mit unserem Standalone-Helfer ein:
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideNavigationConfirm } from './pending-changes';
import { CustomNavigationConfirmService } from './custom-navigation-confirm.service';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
// Ersetzen des standardmäßigen Browser-Confirms durch unseren Material-Dialog-Service
provideNavigationConfirm(CustomNavigationConfirmService)
]
};
Schritt 5: Einfaches Unit-Testing
Einer der größten Vorteile der Entkopplung von Bestätigungsdialogen mit Providern ist das Unit-Testing. Wir müssen keine Window-Objekte mehr mocken oder den headless laufenden Testrunner blockieren.
In unseren Unit-Tests können wir einfach eine Mock-Implementierung bereitstellen, die die Navigation automatisch freigibt:
import { TestBed } from '@angular/core/testing';
import { NAVIGATION_CONFIRM } from './pending-changes';
import { pendingChangesGuard } from './pending-changes';
describe('pendingChangesGuard', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: NAVIGATION_CONFIRM,
useValue: {
confirm: () => true // Navigation im Test immer erlauben
}
}
]
});
});
it('should allow navigation when mocked', () => {
const mockComponent = { isDirty: () => true };
const canLeave = TestBed.runInInjectionContext(() =>
pendingChangesGuard(mockComponent)
);
expect(canLeave).toBe(true);
});
});
Fazit
Durch die Kombination der funktionalen Guards von Angular mit benutzerdefinierten Providern gelingt uns der Aufbau einer sauberen Routing-Architektur. Der Guard bleibt vollständig von der UI-Präsentationsebene isoliert, was es Entwicklern ermöglicht, nahtlos zwischen Browser-Dialogen, eigenen Modals aus dem Designsystem oder Mock-Konfigurationen für Unit-Tests zu wechseln.
