Angular use modal dialog in canDeactivate Guard service for unsubmitted changes (Form dirty)

Viewed 26437

In my Angular 4 application I have some components with a form, like this:

export class MyComponent implements OnInit, FormComponent {

  form: FormGroup;

  ngOnInit() {
    this.form = new FormGroup({...});
  }

they use a Guard service to prevent unsubmitted changes to get lost, so if the user tries to change route before it will ask for a confirmation:

import { CanDeactivate } from '@angular/router';
import { FormGroup } from '@angular/forms';

export interface FormComponent {
  form: FormGroup;
}

export class UnsavedChangesGuardService implements CanDeactivate<FormComponent> {
  canDeactivate(component: FormComponent) {
    if (component.form.dirty) {
      return confirm(
        'The form has not been submitted yet, do you really want to leave page?'
      );
    }

    return true;
  }
}

This is using a simple confirm(...) dialog and it works just fine.

However I would like to replace this simple dialog with a more fancy modal dialog, for example using the ngx-bootstrap Modal.

How can I achieve the same result using a modal instead?

9 Answers

I solved it using ngx-bootstrap Modals and RxJs Subjects.

First of all I created a Modal Component:

import { Component } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { BsModalRef } from 'ngx-bootstrap';

@Component({
  selector: 'app-confirm-leave',
  templateUrl: './confirm-leave.component.html',
  styleUrls: ['./confirm-leave.component.scss']
})
export class ConfirmLeaveComponent {

  subject: Subject<boolean>;

  constructor(public bsModalRef: BsModalRef) { }

  action(value: boolean) {
    this.bsModalRef.hide();
    this.subject.next(value);
    this.subject.complete();
  }
}

here's the template:

<div class="modal-header modal-block-primary">
  <button type="button" class="close" (click)="bsModalRef.hide()">
    <span aria-hidden="true">&times;</span><span class="sr-only">Close</span>
  </button>
  <h4 class="modal-title">Are you sure?</h4>
</div>
<div class="modal-body clearfix">
  <div class="modal-icon">
    <i class="fa fa-question-circle"></i>
  </div>
  <div class="modal-text">
    <p>The form has not been submitted yet, do you really want to leave page?</p>
  </div>
</div>
<div class="modal-footer">
  <button class="btn btn-default" (click)="action(false)">No</button>
  <button class="btn btn-primary right" (click)="action(true)">Yes</button>
</div>

Then I modified my guard using a Subject, now it look like this:

import { CanDeactivate } from '@angular/router';
import { FormGroup } from '@angular/forms';
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { BsModalService } from 'ngx-bootstrap';

import { ConfirmLeaveComponent } from '.....';

export interface FormComponent {
  form: FormGroup;
}

@Injectable()
export class UnsavedChangesGuardService implements CanDeactivate<FormComponent> {

  constructor(private modalService: BsModalService) {}

  canDeactivate(component: FormComponent) {
    if (component.form.dirty) {
      const subject = new Subject<boolean>();

      const modal = this.modalService.show(ConfirmLeaveComponent, {'class': 'modal-dialog-primary'});
      modal.content.subject = subject;

      return subject.asObservable();
    }

    return true;
  }
}

In app.module.ts file go to the @NgModule section and add the ConfirmLeaveComponent component to entryComponents.

@NgModule({
  entryComponents: [
    ConfirmLeaveComponent,
  ]
})

Just expanding on the additional info provided by mitschmidt regarding click outside / escape button, this canDeactivate method works with Francesco Borzi's code. I just add the subscribe to onHide() inline in the function:

canDeactivate(component: FormComponent) {
        if (component.form.dirty) {
            const subject = new Subject<boolean>();

            const modal = this.modalService.show(ConfirmLeaveComponent, { 'class': 'modal-dialog-primary' });
            modal.content.subject = subject;

            this.modalService.onHide.subscribe(hide => {
                subject.next(false);
                return subject.asObservable();
            });

            return subject.asObservable();
        }

        return true;
    }

Since I have been going back and forth with a Ashwin, I decided to post my solution that i have with Angular and Material.

Here is my StackBlitz

This works, but I wanted add the complexity of an asynchronous response from the Deactivating page like how I have it in my application. This is bit of a process so bear with me please.

This is my implementation to get a confirmation dialog before leaving a certain route using ngx-bootstrap dialog box. I am having a global variable called 'canNavigate' with the help of a service. This variable will hold a Boolean value if it is true or false to see if navigation is possible. This value is initially true but if I do a change in my component I will make it false therefore 'canNavigate' will be false. If it is false I will open the dialog box and if the user discards the changes it will go to the desired route by taking the queryParams as well, else it will not route.

@Injectable()
export class AddItemsAuthenticate implements CanDeactivate<AddUniformItemComponent> {

  bsModalRef: BsModalRef;
  constructor(private router: Router,
              private dataHelper: DataHelperService,
              private modalService: BsModalService) {
  }

  canDeactivate(component: AddUniformItemComponent,
                route: ActivatedRouteSnapshot,
                state: RouterStateSnapshot,
                nextState?: RouterStateSnapshot): boolean {
    if (this.dataHelper.canNavigate === false ) {
      this.bsModalRef = this.modalService.show(ConfirmDialogComponent);
      this.bsModalRef.content.title = 'Discard Changes';
      this.bsModalRef.content.description = `You have unsaved changes. Do you want to leave this page and discard
                                            your changes or stay on this page?`;

      this.modalService.onHidden.subscribe(
        result => {
          try {
            if (this.bsModalRef && this.bsModalRef.content.confirmation) {
              this.dataHelper.canNavigate = true;
              this.dataHelper.reset();;
              const queryParams = nextState.root.queryParams;
              this.router.navigate([nextState.url.split('?')[0]],
                {
                  queryParams
                });
            }
          }catch (exception) {
            // console.log(exception);
          }
        }, error => console.log(error));
    }

    return this.dataHelper.canNavigate;

  }
}

You can pass a value to the afterClosed Observable of the dialog:

// modal.component.html
<mat-dialog-actions>
  <button mat-button mat-dialog-close>Cancel</button>
  <button mat-button [mat-dialog-close]="true">Leave</button>
</mat-dialog-actions>
// unsaved-changes.service.ts
@Injectable({ providedIn: 'root' })
export class UnsavedChangesGuardService
  implements CanDeactivate<FormComponent> {
  constructor(private _dialog: MatDialog) {}

  canDeactivate(component: FormComponent) {
    if (component.form.dirty) {
      const dialogRef = this._dialog.open(UnsavedChangesDialogComponent);
      return dialogRef.afterClosed();
    }

    return true;
  }
}

I implemented this solution with Angular Material Dialog:

Material's modal has "componentInstance" instead of "content" in ngx-bootstrap Modals:

if (component.isDirty()) {
  const subject = new Subject<boolean>();
  const modal = this.dialog.open(ConfirmationDialogComponent, {
    panelClass: 'my-panel', width: '400px', height: '400px',
  });

  modal.componentInstance.subject = subject;
  return subject.asObservable()
}
  return true;
}

Here is a working solution without subject, you can add a boolean property confirmed to distinguish is the user has clicked cancel or confirm

import { Component, OnInit } from '@angular/core';
import { BsModalRef } from 'ngx-bootstrap/modal';

@Component({
  selector: 'app-leave-form-confirmation',
  templateUrl: './leave-form-confirmation.component.html',
  styleUrls: ['./leave-form-confirmation.component.scss']
})
export class LeaveFormConfirmationComponent implements OnInit {
  confirmed = false;
  constructor(public bsModalRef: BsModalRef) { }

  ngOnInit(): void {
  }
  confirm = () => {
    this.confirmed= true;
    this.bsModalRef.hide()
  }
}

and here is the html

<div class="modal-header">
  <h4 class="modal-title pull-left">Confirmation</h4>
</div>
<div class="modal-body">
  <h2>Data will be lost, Are you sure to leave the form?</h2>
</div>-*
<div class="modal-footer">
  <button type="button" class="btn btn-default" (click)="confirm()">confirm</button>
  <button type="button" class="btn btn-default" (click)="bsModalRef.hide()">cancel</button>
</div>

and here is your canDeactivate method

 canDeactivate(
    component: DataStatus,
    currentRoute: ActivatedRouteSnapshot,
    currentState: RouterStateSnapshot,
    nextState?: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
    if(!component.isDataSaved()) {
      const modalRef = this.modalService.show(LeaveFormConfirmationComponent,  {
        backdrop: false,
        ignoreBackdropClick: true
      })
      return modalRef.onHidden.pipe(map(_ => modalRef.content.confirmed));
    }
    return of(true);
  }

For Material Dialog, you can create a confirmation dialog component with your custom HTML, CSS, and then in guard, you can invoke dialog as below, refer checkConfirmation() function:

import { CanDeactivate } from '@angular/router';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component';

export interface ifCanDeactivateComponent {
  canDeactivate: () => boolean | Observable<boolean> | Promise<boolean>;
}

@Injectable()
export class UnsavedChangesGuard implements CanDeactivate<ifCanDeactivateComponent> {
  constructor(public dialog: MatDialog){}

  //Confirmation dialog to highlight about any of the unsaved changes
  async checkConfirmation(): Promise<boolean> {
    let dialogRef = this.dialog.open(ConfirmationDialogComponent, {
      disableClose: false,
      width: '500px',
    });
    dialogRef.componentInstance.confirmMessage = 'You have unsaved changes. Are you sure to lose changes?'
  
    let res: boolean = await dialogRef.afterClosed().toPromise();

    return res;
  }
  
  //Navigation continues if return true, else navigation is cancelled
  canDeactivate(component: ifCanDeactivateComponent): boolean | Promise<boolean> {
    //Safety check: Check if component implements canDeactivate methods
    if(Object.getPrototypeOf(component).hasOwnProperty('canDeactivate')){
      // if there are no unsaved changes, allow deactivation; else confirm first
      return component.canDeactivate() ? true : this.checkConfirmation();
    } else {
      throw new Error("This component doesn't implement canDeactivate method");
    }
  }
}
Related