loading...

Angular: display a warning and prevent navigation when model is dirty

elasticrash profile image Stefanos Kouroupis Updated on ・3 min read

Angular is one of my favourite frameworks. I know there are a lot haters out there, as well as many lovers, for good reasons. When it comes to enterprise applications, its where it shines.

Today I decided to write on how you can create a component that gets dynamically inserted into the dom and prevent navigation, if and when your view's model is dirty.

This technique is exceptionally handy, especially when you build an application with a lot of forms.

What we try to achieve is basically, if the form has changes (either through creating or editing a form) and the user accidently tries to navigate away, give him a choice.

  • to go back and save his changes
  • or accept that his changes will be lost and navigate away

This is the interface that our routed component needs to implement

  • getRef() returns the component's container reference
  • isDirty() contains our bespoke comparison logic
interface IDirty {
    isDirty(): boolean;
    getRef(): ViewContainerRef;
}

This is the guard service that implements CanDeactivate. Which basically blocks navigation or not by returning true/false.

@Injectable()
export class ModelDirtyGuardService implements CanDeactivate<IDirty> {
    constructor(
        private confirmationDialogService: ConfirmationDialogService,
        private confirmationDialogReferenceService: ConfirmationDialogReferenceService
    ) { }
    public canDeactivate(
        component: IDirty,
        currentRoute: ActivatedRouteSnapshot,
        currentState: RouterStateSnapshot,
        nextState: RouterStateSnapshot
    ): Observable<boolean> | Promise<boolean> | boolean {
        let canLeave: boolean = component.isDirty();
        if (canLeave === false) {
            canLeave = this.confirmationDialogService.loadComponent(
              component.getRef(),
              nextState.url
            );
            this.confirmationDialogReferenceService.allow = false;
        } else {
            this.confirmationDialogReferenceService.allow = false;
        }

        return canLeave;
    }
}

ConfirmationDialogService is the service that is responsible for constructing and inserting our component to the dom.

ConfirmationDialogReferenceService holds a global state of the component we want to insert.

what is happening is simple

  • we check if the model is dirty by using the isDirty function that our component needs to implement. One of the things that we are not covering here, is how to check if the form/model is dirty. Depends on your application logic.
  • if the model is dirty that means that we need to insert our component (ConfirmationDialogComponent) into the view, through a service (ConfirmationDialogService).
  • set the state allow through the ConfirmationDialogReferenceService
@Injectable()
export class ConfirmationDialogService {
    answer: boolean;
    componentRef: ComponentRef<ConfirmationDialogComponent>;

    constructor(
        private componentFactoryResolver: ComponentFactoryResolver,
        private confirmationDialogReferenceService: ConfirmationDialogReferenceService
    ) { }

    loadComponent(viewContainerRef: ViewContainerRef, nextState) {
        this.confirmationDialogReferenceService.routerState = nextState;
        let componentFactory = this.componentFactoryResolver.resolveComponentFactory(ConfirmationDialogComponent);
        this.componentRef = viewContainerRef.createComponent(componentFactory);
        this.confirmationDialogReferenceService.componentRef = this.componentRef;
        return this.confirmationDialogReferenceService.allow;
    }

}

Next we create our component which implements IDirty and it has two functions

  • closeDialog(), we decide that we need not to navigate away so we need to unload the dynamic component (this is done by destroying it through ConfirmationDialogReferenceService) and stay in the same view.
  • navigateAway() we accept that we agree to navigate away, losing any changes.
export class ConfirmationDialogComponent{

    constructor(
    private confirmationDialogReferenceService: ConfirmationDialogReferenceService) { }

    public closeDialog() {
        this.confirmationDialogReferenceService.unloadComponent();
    }

    public navigateAway() {
        this.confirmationDialogReferenceService.allow = true;
        this.confirmationDialogReferenceService.destroyComponentAndAllowNavigation();
    }
}

Finally we have ConfirmationDialogReferenceService, which keeps the current state of our dynamic ConfirmationDialogComponent component.

The important bits is

  • routerState, which we set the route we need to navigate to
  • unloadComponent, which destroys our component (we stay in the same view)
  • destroyComponentAndAllowNavigation, which destroys our component and let us navigate away
@Injectable()
export class ConfirmationDialogReferenceService {
    private _componentRef: any;
    private _routerState: string;
    private _allow: boolean;

    constructor(
        private router: Router
    ) {

    }

    set componentRef(ref) {
        this._componentRef = ref;
    }

    get componentRef() {
        return this._componentRef;
    }

    set allow(allow) {
        this._allow = allow;
    }

    get allow() {
        return this._allow;
    }

    set routerState(state) {
        this._routerState = state;
    }

    get routerState() {
        return this._routerState;
    }

    public unloadComponent() {
        this.componentRef.destroy();
    }

    public destroyComponentAndAllowNavigation() {
        this.componentRef.destroy();
        this.router.navigate([this.routerState]);
    }

}

Last but not least in order to use the createComponent function, our component needs to be an entryComponents in our Module

    entryComponents: [
        ConfirmationDialogComponent
    ]

Apologies if there are any unused properties left, I tried to clean up. This whole example was lifted from a real world application

Discussion

pic
Editor guide
Collapse
elasticrash profile image
Stefanos Kouroupis Author

I had to make a small amendment. I accidentally implemented IDirty on ConfirmationDialogComponent, but that should only implemented as stated in the beginning on the component that has contains the form

Collapse
ericyu67 profile image
ericyu

Do you have any sample code? I am not sure how to use this idea.

Collapse
elasticrash profile image
Stefanos Kouroupis Author

Once you start implementing its quite straight forward. I don't have any sample code, but I have used it in 3 projects so far. Maybe If I find some time I could do a small poc