Nested Routes

Learning Objectives

  • How to configure child routes.

  • How to define paths relative to the current path or the root of the application.

  • How to get access to parent parameterised route params.

Goal

The goal for this lecture is to change our iTunes search app so that when we click on a search result we are taken to an Artist page.

This Artist page has two more menu items, Tracks and Albums. If we click on Tracks we want to show the list of tracks for this artist, if we click on Albums we want to show the list of albums for this artist.

artist track listing
Figure 1. Artist Track Listing Page
artist album listing
Figure 2. Artist Album Listing Page

We can get information about an Artist using the iTunes API by passing in the id of an artist

A list of tracks for that artist by additionally passing in entity=song:

A list of albums for that artist by additionally passing in entity=album:

The concept of having two sets of menu items. A top level between Home, Search and Artist and a second level under Artist between Tracks and Albums is called Nested Routing and it’s the topic of this lecture.

Setup

We’ve added 3 more components to our sample project and included them in our NgModule declarations:

  1. ArtistComponent — This shows some details about an Artist and contains either the ArtistTrackListComponent or ArtistAlbumListComponent.

  2. ArtistTrackListComponent — This will show a track listing for the current Artist.

  3. ArtistAlbumListComponent — This will show an Album listing for the current Artist.

For now each component’s template just has a heading with the name of the component, like so:

@Component({
  selector: 'app-artist',
  template: `
<h1>Artist</h1>
 `
})
class ArtistComponent {
}
@Component({
  selector: 'app-artist-track-list',
  template: `
<h1>Artist Track Listing</h1>
 `
})
class ArtistTrackListComponent {
}
@Component({
  selector: 'app-artist-album-list',
  template: `
<h1>Artist Album Listing</h1>
 `
})
class ArtistAlbumListComponent {
}

Route Configuration

We need another route for our ArtistComponent, we want to pass to this component an artistId so it needs to be a parameterised route, the aritstId is mandatory so we are not using optional params.

const routes: Routes = [
  {path: '', redirectTo: 'home', pathMatch: 'full'},
  {path: 'find', redirectTo: 'search'},
  {path: 'home', component: HomeComponent},
  {path: 'search', component: SearchComponent},
  {path: 'artist/:artistId', component: ArtistComponent}, (1)
  {path: '**', component: HomeComponent}
];
1 Add a path of 'artist/:artistId' for ArtistComponent

In our search results listing we add a routerLink to the result item so it navigates to our Artist page.

<div class="list-group">
  <a [routerLink]="['artist', track.artistId]" (1)
     class="list-group-item list-group-item-action"
     *ngFor="let track of itunes.results">
    <img src="{{track.thumbnail}}">
    {{ track.name }} <span class="text-muted">by</span> {{ track.artist }}
  </a>
</div>
1 Add routerLink directive.

Relative routes

If we ran the above code you’ll notice that searching for U2 transforms the URL to:

/#/search;term=U2

and then if we click on a U2 song the URL incorrectly becomes

/#/search;term=U2/artist/78500

And we don’t get shown the Artist page.

The URL looks incorrect, it’s a concatenation of the search URL with the artist URL. We should be expecting a URL like so:

/#/artist/78500

That’s because when we navigated to

[routerLink]="['artist', item.artistId]"

we did so relative to the current URL and the current URL at the time was the search URL.

What we want to do is to navigate relative to the _root _of our application, relative to / — we can do that simply by pre-pending / to our path, like so:

[routerLink]="['/artist', item.artistId]"

Now when we navigate we do so relative to the root URL and when we click the first U2 song the URL changes to /#/artist/78500 and we are shown the artist page.

blank artist page

Child routes

When the Artist page is shown we want there to be two menu options, one called Tracks and the other called Albums, like so:

artist page menu items

When a user clicks on Tracks underneath I would like to show a list of all the tracks this artist has released, if the user clicks on Albums then I want to show a list of all the albums they have released.

Explained in terms that the Router would understand:

"Inside the ArtistComponent I want to conditionally show either the AritstAlbumsListComponent or the ArtistTrackListComponent depending on which menu item the user has selected".

Let me first define the route configuration, each route can have a property called children where you can define the child routes of this route. I’m going to add my routes for AritstAlbumsListComponent and ArtistTrackListComponent, like so:

const routes: Routes = [
  {path: '', redirectTo: 'home', pathMatch: 'full'},
  {path: 'find', redirectTo: 'search'},
  {path: 'home', component: HomeComponent},
  {path: 'search', component: SearchComponent},
  {
    path: 'artist/:artistId',
    component: ArtistComponent,
    children: [
      {path: '', redirectTo: 'tracks'}, (1)
      {path: 'tracks', component: ArtistTrackListComponent}, (2)
      {path: 'albums', component: ArtistAlbumListComponent}, (3)
    ]
  },
  {path: '**', component: HomeComponent}
];
1 If a user navigates to say /artist/1234 it will redirect to /artist/1234/tracks (since we would at least want one of either the track or album components to be shown).
2 This route matches a URL like /artist/1234/tracks.
3 This route matches a URL like /artist/1234/albums.

Now I can add the menu items to my artist template, like so:

<h1>Artist</h1>
<p>
  <a [routerLink]="['tracks']">Tracks</a> |
  <a [routerLink]="['albums']">Albums</a>
</p>

Important

Notice that the paths in the routerLink directives above don’t start with / so they are relative to the current URL which is the /artist/:artistId route.

A more explicit way of saying you want to route relative to the current URL is to prepend it with ./ like so:

<h1>Artist</h1>
<p>
  <a [routerLink]="['./tracks']">Tracks</a> |
  <a [routerLink]="['./albums']">Albums</a>
</p>

Note

Pre-pending with ./ clearly expresses our intent that the path is relative so lets use this syntax instead.

The final thing we need to do is to tell Angular where we want the ArtistTrackListComponent and ArtistAlbumListComponent components to be injected into the page, we do that by adding in another router-outlet directive in our Artist template like so:

<h1>Artist</h1>
<p>
  <a [routerLink]="['./tracks']">Tracks</a> |
  <a [routerLink]="['./albums']">Albums</a>
</p>

<router-outlet></router-outlet>

Now we have two router-outlets one nested inside another Angular figures out which outlet to insert the component in by the nesting level of the route and the router outlet.

Parent route params

To query the list of tracks for an Artist from the iTunes API the ArtistTrackListComponent needs the artistId.

As a reminder the route configuration for our ArtistTrackListComponent is

{path: 'tracks', component: ArtistTrackListComponent}

and the route configuration for it’s parent is

{
    path: 'artist/:artistId',
    component: ArtistComponent,
    children: [
      {path: '', redirectTo: 'tracks'},
      {path: 'tracks', component: ArtistTrackListComponent},
      {path: 'albums', component: ArtistAlbumListComponent},
    ]
}

The parent route has the :artistId as a route param, however if we injected ActivatedRoute into our child ArtistTrackListComponent and tried to print out the params surprisingly we just get an empty object printed out.

class ArtistTrackListComponent {
  constructor(private route: ActivatedRoute) {
    this.route.params.subscribe(params => console.log(params)); // Object {}
  }
}

The reason for this is that ActivatedRoute only passes you the parameters for the current components route and since the route for ArtistTrackListComponent doesn’t have any route parameters it gets passed nothing, we want to get the params for the parent route.

We can do this by calling parent on our ActivatedRoute like so:

class ArtistTrackListComponent {
  constructor(private route: ActivatedRoute) {
    this.route.parent.params.subscribe(params => console.log(params)); // Object {artistId: 12345}
  }
}

This returns the params for the parent route.

Summary

We can nest routes, this means that for a given URL we can render a tree of components.

We do this by using multiple router-outlet directives and configuring child routes on our route configuration object.

Next up we will learn about protecting access to different routes via the use of Router Guards.

Note

The application above is complete in terms of child routing but still needs rounding out in terms of making the other API requests for tracks and albums and prettying up the template HTML.

You won’t learn anything else about routing from going through the process of finishing off the app, but for your interest i’ve provided the full listing below.

As with all our examples they are for illustrative purposes only, to follow the official style guides we should be putting each component in a separate file and our http request should all be wrapped in a separate service.

I will however leave that as an exercise for the reader if they so wish.

Listing

Listing 1. script.ts
import {NgModule, Component, Injectable} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {JsonpModule, Jsonp, Response} from '@angular/http';
import {ReactiveFormsModule, FormControl, FormsModule} from '@angular/forms';
import {Routes, RouterModule} from "@angular/router";
import {Observable} from 'rxjs';
import 'rxjs/add/operator/toPromise';
import {Routes, RouterModule, Router, ActivatedRoute} from "@angular/router";

class SearchItem {
  constructor(public name: string,
              public artist: string,
              public link: string,
              public thumbnail: string,
              public artistId: string) {
  }
}

@Injectable()
class SearchService {
  apiRoot: string = 'https://itunes.apple.com/search';
  results: SearchItem[];

  constructor(private jsonp: Jsonp) {
    this.results = [];
  }

  search(term: string) {
    return new Promise((resolve, reject) => {
      this.results = [];
      let apiURL = `${this.apiRoot}?term=${term}&media=music&limit=20&callback=JSONP_CALLBACK`;
      this.jsonp.request(apiURL)
          .toPromise()
          .then(
              res => { // Success
                this.results = res.json().results.map(item => {
                  return new SearchItem(
                      item.trackName,
                      item.artistName,
                      item.trackViewUrl,
                      item.artworkUrl30,
                      item.artistId
                  );
                });
                resolve();
              },
              msg => { // Error
                reject(msg);
              }
          );
    });
  }
}


@Component({
  selector: 'app-search',
  template: `<form class="form-inline">
  <div class="form-group">
    <input type="search"
           class="form-control"
           placeholder="Enter search string"
           #search>
  </div>
  <button type="button"
          class="btn btn-primary"
          (click)="onSearch(search.value)">
    Search
  </button>
</form>

<hr />

<div class="text-center">
  <p class="lead"
     *ngIf="loading">Loading...</p>
</div>

<div class="list-group">
  <a [routerLink]="['/artist', track.artistId]"
     class="list-group-item list-group-item-action"
     *ngFor="let track of itunes.results">
    <img src="{{track.thumbnail}}">
    {{ track.name }} <span class="text-muted">by</span> {{ track.artist }}
  </a>
</div>
 `
})
class SearchComponent {
  private loading: boolean = false;

  constructor(private itunes: SearchService,
              private route: ActivatedRoute,
              private router: Router) {
    this.route.params.subscribe(params => {
      console.log(params);
      if (params['term']) {
        this.doSearch(params['term'])
      }
    });
  }

  doSearch(term: string) {
    this.loading = true;
    this.itunes.search(term).then(_ => this.loading = false)
  }

  onSearch(term: string) {
    this.router.navigate(['search', {term: term}]);
  }
}

@Component({
  selector: 'app-home',
  template: `
<div class="jumbotron">
  <h1 class="display-3">iTunes Search App</h1>
</div>
 `
})
class HomeComponent {
}

@Component({
  selector: 'app-header',
  template: `<nav class="navbar navbar-light bg-faded">
  <a class="navbar-brand"
     [routerLink]="['home']">iTunes Search App
  </a>
  <ul class="nav navbar-nav">
    <li class="nav-item"
        [routerLinkActive]="['active']">
      <a class="nav-link"
         [routerLink]="['home']">Home
      </a>
    </li>
    <li class="nav-item"
        [routerLinkActive]="['active']">
      <a class="nav-link"
         [routerLink]="['search']">Search
      </a>
    </li>
  </ul>
</nav>
 `
})
class HeaderComponent {
  constructor(private router: Router) {
  }

  goHome() {
    this.router.navigate(['']);
  }

  goSearch() {
    this.router.navigate(['search']);
  }
}

@Component({
  selector: 'app-artist-track-list',
  template: `
<ul class="list-group">
	<li class="list-group-item"
	    *ngFor="let track of tracks">
		<img src="{{track.artworkUrl30}}">
		<a target="_blank"
		   href="{{track.trackViewUrl}}">{{ track.trackName }}
		</a>
	</li>
</ul>
 `
})
class ArtistTrackListComponent {
  private tracks: any[];

  constructor(private jsonp: Jsonp,
              private route: ActivatedRoute) {
    this.route.parent.params.subscribe(params => {
      this.jsonp.request(`https://itunes.apple.com/lookup?id=${params['artistId']}&entity=song&callback=JSONP_CALLBACK`)
          .toPromise()
          .then(res => {
            console.log(res.json());
            this.tracks = res.json().results.slice(1);
          });
    });
  }
}

@Component({
  selector: 'app-artist-album-list',
  template: `<ul class="list-group">
	<li class="list-group-item"
	    *ngFor="let album of albums">
		<img src="{{album.artworkUrl60}}">
		<a target="_blank"
		   href="{{album.collectionViewUrl}}">{{ album.collectionName }}
		</a>
	</li>
</ul>
 `
})
class ArtistAlbumListComponent {
  private albums: any[];

  constructor(private jsonp: Jsonp,
              private route: ActivatedRoute) {
    this.route.parent.params.subscribe(params => {
      this.jsonp.request(`https://itunes.apple.com/lookup?id=${params['artistId']}&entity=album&callback=JSONP_CALLBACK`)
          .toPromise()
          .then(res => {
            console.log(res.json());
            this.albums = res.json().results.slice(1);
          });
    });
  }
}

@Component({
  selector: 'app-artist',
  template: `<div class="card">
  <div class="card-block">
    <h4>{{artist?.artistName}} <span class="tag tag-default">{{artist?.primaryGenreName}}</span></h4>
    <hr />
    <footer>
      <ul class="nav nav-pills">
        <li class="nav-item">
          <a class="nav-link"
             [routerLinkActive]="['active']"
             [routerLink]="['./tracks']">Tracks
          </a>
        </li>
        <li class="nav-item">
          <a class="nav-link"
             [routerLinkActive]="['active']"
             [routerLink]="['./albums']">Albums
          </a>
        </li>
      </ul>
    </footer>
  </div>
</div>

<div class="m-t-1">
  <router-outlet></router-outlet>
</div>
 `
})
class ArtistComponent {
  private artist: any;

  constructor(private jsonp: Jsonp,
              private route: ActivatedRoute) {
    this.route.params.subscribe(params => {
      this.jsonp.request(`https://itunes.apple.com/lookup?id=${params['artistId']}&callback=JSONP_CALLBACK`)
          .toPromise()
          .then(res => {
            console.log(res.json());
            this.artist = res.json().results[0];
            console.log(this.artist);
          });
    });
  }
}

@Component({
  selector: 'app',
  template: `
	<app-header></app-header>
	<div class="m-t-1">
    <router-outlet></router-outlet>
  </div>
 `
})
class AppComponent {
}

const routes: Routes = [
  {path: '', redirectTo: 'home', pathMatch: 'full'},
  {path: 'find', redirectTo: 'search'},
  {path: 'home', component: HomeComponent},
  {path: 'search', component: SearchComponent},
  {
    path: 'artist/:artistId',
    component: ArtistComponent,
    children: [
      {path: '', redirectTo: 'tracks', pathMatch: 'full'},
      {path: 'tracks', component: ArtistTrackListComponent},
      {path: 'albums', component: ArtistAlbumListComponent},
    ]
  },
  {path: '**', component: HomeComponent}
];


@NgModule({
  imports: [
    BrowserModule,
    ReactiveFormsModule,
    FormsModule,
    JsonpModule,
    RouterModule.forRoot(routes, {useHash: true})
  ],
  declarations: [
    AppComponent,
    SearchComponent,
    HomeComponent,
    HeaderComponent,
    ArtistAlbumListComponent,
    ArtistTrackListComponent,
    ArtistComponent
  ],
  bootstrap: [AppComponent],
  providers: [SearchService]
})
class AppModule {
}

platformBrowserDynamic().bootstrapModule(AppModule);

Learn Angular 5 For FREE

I've released my 700 page Kick Starter funded Angular 5 book for FREE