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

9

20

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?

Delilahdelimit answered 26/9, 2017 at 18:13 Comment(4)
I've used a few different modals and always found that the best approach is use the service with a component. Pretty straightforward stuff. What part are you unclear about?Oeildeboeuf
about canDeactivate returning a value based on the user's inputDelilahdelimit
So the return type of canDeactivate needs to be Observable<boolean>|Promise<boolean>|boolean. So, I'd look for a modal where the result returns one of those. Check this out ... github.com/dougludlow/ng2-bs3-modalOeildeboeuf
@Oeildeboeuf that modal requires jQuery which I would avoid to include...Delilahdelimit
D
38

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,
  ]
})
Delilahdelimit answered 27/9, 2017 at 14:53 Comment(11)
This example also works with Angular Material as well, following the Subject implementation!Oliana
I would love to understand why returning an Observable to the CanDeactivate() method works?Oliana
@AndrewLobban canDeactivate method in UnsavedChangeGuardService is overriding a method from CanDeactivate Interface which has return type set to Observable<boolean>|Promise<boolean>|boolean, so we can pass value having Observable<boolean> as return type in canDeactivate methodHeadcloth
@Headcloth thanks for the explanation. I thought it was a lot more complicated than that.Oliana
@Francesco Borzì, According to this code if the user types something on the form and reverses the modification, the form would still warn the user. Do you have any suggestion for me to avoid that?Minimus
@KushanRandima that happens because the check is if (component.form.dirty), you can change it and implement your own logic there in order to compare the old form value and the new form value. As far as I know there is no such built-in functionality in Angular.Delilahdelimit
@AndrewLobban - Do you have a working example of this?Trista
@Trista I had an example. What issue are you trying to solve?Oliana
@AndrewLobban - What happens is when a custom confirmation modal is being displayed till that time the UI behind it keeps working (such as changing route view, sending request etc). This does not happens when I use window.confirm box (every thing is stopped, resumes on an action of confirm box). I am looking for a working example same as window.confirm but by using bootstrap modal or material.Trista
@Trista I have added my StackBlitz solution as well. https://stackoverflow.com/a/57083828/2975759. Let me know of there if there is something specific you need and I will do my best to assist.Oliana
Very smart idea! Thank you :)Alduino
E
4

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;
    }
Exceptive answered 6/8, 2019 at 23:5 Comment(0)
H
3

In addition to ShinDarth's good solution, it seems worth mentioning that you will have to cover a dismissal of the modal as well, because the action() method might not be fired (e.g. if you allow the esc button or click outside of the modal). In that case the observable never completes and your app might get stuck if you use it for routing.

I achieved that by subscribing to the bsModalService onHide property and merging this and the action subject together:

confirmModal(text?: string): Observable<boolean> {
    const subject = new Subject<boolean>();
    const modal = this.modalService.show(ConfirmLeaveModalComponent);
    modal.content.subject = subject;
    modal.content.text = text ? text : 'Are you sure?';
    const onHideObservable = this.modalService.onHide.map(() => false);
    return merge(
      subject.asObservable(),
      onHideObservable
    );
  }

In my case I map the mentioned onHide observable to false because a dismissal is considered an abort in my case (only a 'yes' click will yield a positive outcome for my confirmation modal).

Haemolysin answered 15/2, 2018 at 15:2 Comment(0)
O
2

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.

Oliana answered 17/7, 2019 at 20:44 Comment(1)
Thank you for paying so much attention for this issue. I was able to achieve the same thing that you have provided in your StackBlitz. However, I don't want it this way. If you notice, there the counter keeps on incrementing when the dialog is displayed. It does not increase when there is a traditional window.confirm alert and it resumes from exactly that point when cancel is clicked. Please look at this - stackblitz.com/edit/angular-custom-confirmation-bx66hh to understand what I am talking about. I don't think so that this behaviour is achievable without window.confirm.Trista
F
1

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;

  }
}
Frazier answered 25/1, 2018 at 4:53 Comment(0)
H
1

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;
  }
}
Hovis answered 3/12, 2020 at 15:4 Comment(0)
E
0

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;
}
Exemplify answered 15/7, 2019 at 6:52 Comment(0)
G
0

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);
  }
Girdle answered 18/7, 2021 at 22:16 Comment(0)
B
0

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");
    }
  }
}
Beatty answered 17/9, 2022 at 8:14 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.