Routing to Angular Material Dialogs

Many Angular developers chose to use Angular Material because it provides a huge set of components that reduce the complexity of building user experiences. One of my favorites has been the Dialog component.

The default functionality provided by Material allows developers to launch a dialog by injecting the MatDialog service in a component’s constructor, then calling the service's open function, passing in the component to be shown in a dialog. We can also pass data to the dialog component and configure the layout of the dialog:

But as users of the web, we expect to be able to navigate to an experience by following a link (in the above example, clicking “Open Dialog” anchor) or by entering the URL directly. How can we provide the latter using Material Dialogs?

Example use case: I was to send an email to my online store’s customers with a direct link to a “Add a Payment Account” dialog.

Options:

  1. Add a query parameter to the parent component’s path, access it using ActivatedRoute, and open the dialog.
  2. Add a child path to the parent’s route configuration that opens the dialog.

The first option makes the passing of data between parent and child dialog easy. We can take data from our component and send it to the child dialog component using the open function. The second option makes traditional data passing more difficult, but the difficulty can be mitigated if your application maintains a “global state” (NgRx, for example) where you can access the data. The second option removes most dialog-specific logic from the parent component and into a route configuration, allowing for less code duplication if the dialog will be opened from multiple parents.

We will base our solutions on the Angular Material Dialog documentation example, which shows the default functionality. Feel free to get familiar with the example application’s behavior.

You may follow along using the example on StackBlitz (embedded below).

Behavior: DialogOverviewExample has a text field for your name. When clicking the “Pick one” button, it passes your name to DialogOverviewExampleDialog which is displayed in an Angular Material Dialog. It has a text field for your favorite animal. When clicking the “Ok” button, it closes the dialog and passes your favorite animal back to DialogOverviewExample, where it is displayed. When clicking the “No Thanks” button, it closes the dialog and clears the favorite animal from the DialogOverviewExample.

Prerequisite: Add the Router Module

In both options, we’ll need to import and configure the Router Module to allow us to define routes.

In main.ts, add the router configuration for our parent component:

import {RouterModule} from '@angular/router';
...
imports: [
...
RouterModule.forRoot([
{
path: 'home',
component: DialogOverviewExample
},
{
path: '**', // bonus: all routes not defined forward to /home
redirectTo: 'home'
}
])
]

Next let’s create a component that will hold our router-outlet:

import { Component } from '@angular/core';
@Component({
selector: 'my-app',
template: `
<div class="container">
<router-outlet></router-outlet>
</div>
`
})
export class AppComponent { }

Then declare and bootstrap AppComponent in main.ts, and replace the dialog-overview-example tag with my-app in index.html.

We can now navigate to our parent component via the path “/home”.


Option 1: Routing by Query Parameter

Add a query parameter to the parent component’s path, access it using ActivatedRoute, and then open the dialog.

We will open the dialog when the user navigates to “/home?dialog=true”.

In the parent template (dialog-overview-example.html), remove the existing example functionality that opens the dialog by directly calling the openDialog function. Replace it with a routerLink with the defined query parameters:

<button mat-raised-button routerLink="" [queryParams]="{dialog: true}">Pick one</button>

In the parent component (DialogOverviewExample in dialog-overview-example.ts), subscribe to the ActivatedRoute’s query parameters, and open the dialog if the dialog query parameter exists.

export class DialogOverviewExample implements OnDestroy {
animal: string;
name: string;
routeQueryParams$: Subscription;
  constructor(
public dialog: MatDialog,
private route: ActivatedRoute
) {
this.routeQueryParams$ = route.queryParams.subscribe(params => {
if (params['dialog']) {
this.openDialog();
}
});

}
  ngOnDestroy() {
this.routeQueryParams$.unsubscribe();
}
...
}

Now our dialog opens when the user navigates to “/home?dialog=true”, but it does not return to “/home” after the dialog is closed.

Remove the query parameter by navigating to the current route without query parameters in the MatDialog afterClosed subscription:

constructor(
public dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {
...
}
openDialog(): void {
const dialogRef = this.dialog.open(DialogOverviewExampleDialog, {
width: '250px',
data: {name: this.name, animal: this.animal}
});
  dialogRef.afterClosed().subscribe(result => {
console.log('The dialog was closed');
this.animal = result;
this.router.navigate(['.'], { relativeTo: this.route });
});
}

Done!

This option is pretty straightforward. As mentioned, a drawback to this option is the amount of dialog-specific code that complicates the parent component. For each parent component, we have to add a query parameter subscription, handle opening the dialog, and handle cleaning up after the dialog is closed.

Here’s the final product:


Option 2: Routing by Path

Add a child path to the parent’s route configuration that opens the dialog.

We will open the dialog when the user navigates to “/home/dialog”.

This option is not as straightforward. Where do we call the MatDialog open from? We cannot arbitrarily execute a function from the Route configuration. We’d need to do it from a component or guard.

// This is NOT possible with the RouterModule...
RouterModule.forRoot([
{
path: 'home',
component: DialogOverviewExample,
children: [
{
path: 'dialog',
executeFunction: openDialog()

}
]
},
{ path: '**', redirectTo: 'home' }
])

We can create a dummy component for the path ‘dialog’ that manages opening, closing, and passing data to the dialog. When the dummy component gets initialized on ‘dialog’ navigation, it will open the dialog.

We’ll call our dummy component DialogEntryComponent:

@Component({
template: ''
})
export class DialogEntryComponent {
constructor(public dialog: MatDialog) {
this.openDialog();
}
  openDialog(): void {
const dialogRef = this.dialog.open(DialogOverviewExampleDialog, {
width: '250px'
});
    dialogRef.afterClosed().subscribe(result => {
console.log('The dialog was closed');
});
}
}

And our simplified DialogOverviewExample:

@Component({
selector: 'dialog-overview-example',
templateUrl: 'dialog-overview-example.html',
styleUrls: ['dialog-overview-example.css']
})
export class DialogOverviewExample {
// MatDialog code moved
// open/close handled in DialogEntryComponent

}

Declare the dummy component and configure the Router Module to load it on ‘dialog’ navigation in main.ts:

imports: [
...
RouterModule.forRoot([
{
path: 'home',
component: DialogOverviewExample,
children: [
{
path: 'dialog',
component: DialogEntryComponent
}
]

},
{ path: '**', redirectTo: 'home' }
])
],
declarations: [
...
DialogEntryComponent
]

In order to provide a launch point for the child route, we’ll need to add a router-outlet to the parent template (dialog-overview-example.html):

<ol>
...
</ol>
<router-outlet></router-outlet>

In the same file, remove the existing example functionality that opens the dialog by directly calling the openDialog function. Replace it with a routerLink to the child component:

<button mat-raised-button routerLink="dialog">Pick one</button>

Now our dialog opens when the user navigates to “/home/dialog”, but it does not return to “/home” after the dialog is closed.

Navigate back to the parent in the MatDialog afterClosed subscription:

@Component({
template: ''
})
export class DialogEntryComponent {
constructor(
public dialog: MatDialog,
private router: Router,
private route: ActivatedRoute

) {
this.openDialog();
}
  openDialog(): void {
const dialogRef = this.dialog.open(DialogOverviewExampleDialog, {
width: '250px'
});
    dialogRef.afterClosed().subscribe(result => {
this.router.navigate(['../'], { relativeTo: this.route });
});
}
}

Our app now correctly navigates to and from “/home/dialog”, but the data is not being passed as expected.

How do we pass the data? Our dummy component doesn’t have direct access to the inputted data. As discussed in our introduction, the direct access to inputted data to pass along to the dialog is a benefit of option 1. We can instead pass data using a service (NgRx, local storage, etc). For this example, we’ll create a dummy service:

import { Injectable } from '@angular/core';
@Injectable()
export class DataService {
animal: string;
name: string;
}

We’ll need to add the DataService to the providers array in main.ts.

Inject the DataService in the parent component:

export class DialogOverviewExample {
constructor(public dataService: DataService) { }
}

Update the parent component template to use the DataService:

<ol>
<li>
<mat-form-field>
<input matInput [(ngModel)]="dataService.name" placeholder="What's your name?">
</mat-form-field>
</li>
...
<li *ngIf="dataService.animal">
You chose: <i>{{dataService.animal}}</i>
</li>
</ol>
<router-outlet></router-outlet>

Inject the DataService in the child dialog component. We’ll pass back data to the parent component using the service instead of the MatDialog afterClosed result parameter:

export class DialogOverviewExampleDialog {
animal: string;
name: string;

constructor(
public dialogRef: MatDialogRef<DialogOverviewExampleDialog>,
private dataService: DataService
) {
this.animal = dataService.animal;
this.name = dataService.name;

}

onNoClick(): void {
this.dialogRef.close();
}
}

Update the child dialog template to use the animal and name properties:

<h1 mat-dialog-title>Hi {{name}}</h1>
<div mat-dialog-content>
<p>What's your favorite animal?</p>
<mat-form-field>
<input matInput [(ngModel)]="animal">
</mat-form-field>
</div>
...

We want to pass data back to the parent if the user clicks the “Ok” button and clear the data if user clicks the “No Thanks” button. Update the child dialog template to handle the click instead of passing the animal back using the mat-dialog-close directive:

...
<div mat-dialog-actions>
<button mat-button (click)="onNoClick()">No Thanks</button>
<button mat-button (click)="onOkClick()" cdkFocusInitial>Ok</button>
</div>

Update the DataService animal property from the child dialog component when the user clicks the “Ok” button, clear the DataService animal property when the user clicks the “No Thanks” button:

export class DialogOverviewExampleDialog {
...
onNoClick(): void {
this.dataService.animal = undefined;
this.dialogRef.close();
}
  onOkClick(): void {
this.dataService.animal = this.animal;
this.dialogRef.close();
}

}

Done!

This option favors applications that maintain state globally and have dialogs that need to exist at multiple route locations (a store checkout dialog at /camera/checkout, /accessories/checkout, etc). After creating a generic DialogEntryComponent, we only need to add the ‘checkout’ child route in the Router Module configuration and add a router-outlet to the parent’s template.

Here’s the final product:

Follow me on Twitter :) @john_crowson