Angular2 Menus, Navigation, and Dialogs

Home / Angular2 Menus, Navigation, and Dialogs

angular_small

In my previous post detailing how to create a DialogService for Angular2, I provided a simple service to display a confirmation dialog. The idea that I had in mind when writing that was to incorporate it into Angular2’s Routing lifecycle with a Menu system. Ultimately, it would be used to determine dirty states and display a dialog to the user asking if they want to keep or discard their changes.


Creating a menu with router links and active states in Angular2 is simple. The out of the box directives for this are pretty straight forward. Consider if I wrap some route anchors into a Bootstrap style navigation menu. This would be my top-level “app.html.” The AppComponent will be initialized with the menu always visible and the router-outlet will be below the menu.

<div class="container-fluid">
  <nav class="navbar navbar-light bg-faded rounded navbar-toggleable">
    <button (click)="toggleMenu()" class="navbar-toggler navbar-toggler-right" type="button" data-toggle="collapse" data-target="#containerNavbar" aria-controls="containerNavbar" aria-expanded="false" aria-label="Toggle navigation">
      <span class="navbar-toggler-icon"></span>
    </button>
    <a class="navbar-brand" href="#">Navbar</a>

    <div class="collapse navbar-collapse" [ngClass]="{'show': isMenuExpanded}" id="containerNavbar">
      <ul class="navbar-nav mr-auto">
        <li class="nav-item"><a class="nav-link" [routerLink]="['/']" [routerLinkActive]="['active']" [routerLinkActiveOptions]="{exact: true}">Route1</a></li>
        <li class="nav-item"><a class="nav-link" [routerLink]="['route2']" [routerLinkActive]="['active']">Route2</a></li>
        <li class="nav-item"><a class="nav-link" [routerLink]="['route3']" [routerLinkActive]="['active']">Route3</a></li>
      </ul>
    </div>
  </nav>
  <template ngbModalContainer></template>
</div>
<br/>
<router-outlet></router-outlet>

You can see in the mark-up above that the (3) routes use the routerLink and routerLinkActive directives. The routerLinkActive simply applies a class (active) when the route is active. This ties in nicely with Bootstrap’s stylings.

Consider a typical use case where each route has the possibility of displaying user-editable data within a form. If the user is the middle of making edits, and the form is in a dirty state, we want to prevent the default routing behavior of navigating away from the current route when a router link is clicked. This is where we would want to display a dialog to the user to give them the option to navigate away, discarding their changes, or to remain on the current route.

Angular2 provides built-in mechanisms for this purpose. They are called “guards.” The one in particular that we use to determine if the current route can be navigated from is the “CanDeactivate” guard. Since I ultimately plan to have a navigation service to intercept all navigation, I create my own implementation of the CanDeactivate as my NavigationService. The premise is that if a class implements “CanDeactivate<T>,” the the T route component is expected to provide a method that the implemented class can call to get the component’s state. These mechanisms are explained very well within the Angular2 samples.

My implementation is pretty simple. It looks like this:

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

@Injectable()
export class NavigationService implements CanDeactivate<CanComponentDeactivate>, CanActivate {
  constructor(private dialogService: DialogService) {}
    
  canDeactivate(component: CanComponentDeactivate) {
    if (!component.canDeactivate) {
      return new Promise<boolean>(resolve => { return resolve(true); });
    }
    
    var retValue = component.canDeactivate();
      
    if (retValue instanceof Observable) {
      console.log("We have an observable");
      return this.intercept(retValue);
    } else {
      console.log("We have a promise");
      return retValue;
    }
  }

The Router will call this NavigationService for any route that is defined to use it. The ‘canDeactivate’ method will be called and it will receive a handle to the Component that is, potentially, being “deactivated.” As you can see from the interface “CanComponentDeactivate,” the return type from the Component being deactivated is expected to be an Observable, Promise, or simple boolean.

When we get the return type from the Component, this is where things get interesting. You can see that if the Component does not have a ‘canDeactivate’ method, we return a Promise and resolve it to true to indicate that navigating for the route is allowed.

In the case of receiving an Observable, things get more interesting. This is where I intercept the observable to display a confirmation dialog from the DialogService.

  
  private intercept(observable: Observable<any>): Observable<any> {
    return observable
      .map((res) => { console.log("Mapped: " + res); return res; })
      .flatMap((res) => {
        // Inverse logic - false means deactivate of route is not allowed (hasChanges is true) 
        if (res === false) {
          console.log("Showing confirm dialog.");
          var modalPromise = this.dialogService.confirm();
          var newObservable = Observable.fromPromise(modalPromise);
          newObservable.subscribe((res) => {
            if (res === true) {
              console.log("Navigation allowed.");
            } else {
              console.log("Navigation prevented.");
            }
          })
          return newObservable; 
        } else {
          return Observable.of(res);
        }
      });
  }
}

The intercept method was interesting for me since I have not worked with Observables much. In order to intercept the Observable and inject the Promise that is returned from the DialogService, The map and flatMap methods are used. The map method, in my mind, is akin to subscribing to the observable to force it to resolve. It is a transforming method. After we map the observable, we will have the true or false value indicating whether or not the underlying Component “CanDeactivate.” Within this context, it is synonymous with “HasChanges.” If the Component HasChanges, then the DialogService is called. If the DialogService’s Promise returns a true value, then the user clicked “Ok” which allows Navigation to continue. If the user clicks “Cancel,” then they stay put with Navigation being prevented.

All of my route Components are basically the same. They have a “hasChanges” boolean which is accessible via a Checkbox. Clicking the checkbox is, effectively, the same as toggling the dirty state. The “canDeactivate” method, then, in each route Component returns the inverted value of hasChanges as an Observable.

export class Route1Component implements OnInit {
    public hasChanges: bool = true;
    
    constructor(private changeRef: ChangeDetectorRef, private appRef: ApplicationRef) {}

    canDeactivate() {
      console.log("Detecting changes. Has Changes: " + this.hasChanges);
      return Observable.of(!this.hasChanges);
    }
    
    ngOnInit() {
    }
}

Finally, as I mentioned above, the routes for which the “CanDeactivate” is to be checked should reference the NavigationService.

const appRoutes: Routes = [
    { path: '', component: Route1Component, data: { name: 'Route1' }, canDeactivate: [ NavigationService ] },
    { path: 'route2', component: Route2Component, data: { name: 'Route2' }, canDeactivate: [ NavigationService ]  },
    { path: 'route3', component: Route3Component, data: { name: 'Route3' }, canDeactivate: [ NavigationService ]  }
];

With all of those pieces in place, the Menu, NavigationService, and DialogService create a pretty decent user experience. I dug into this, for the most part, out of necesity. Neither the demos provided by the Angular2 team, nor any other demos that I could find, illustrated handling the guards with anything more than the stock JavaScript “window.confirm” method. A fully working plunk is below.

2 thoughts on “Angular2 Menus, Navigation, and Dialogs”

Leave a Reply