Milestone 3: Heroes feature
You've seen how to navigate using the
RouterLink
directive. Now you'll learn the following:- Organize the app and routes into feature areas using modules.
- Navigate imperatively from one component to another.
- Pass required and optional information in route parameters.
This example recreates the heroes feature in the "Services" episode of the Tour of Heroes tutorial, and you'll be copying much of the code from the Tour of Heroes: Services example code / download example .
Here's how the user will experience this version of the app:
A typical application has multiple feature areas, each dedicated to a particular business purpose.
While you could continue to add files to the
src/app/
folder, that is unrealistic and ultimately not maintainable. Most developers prefer to put each feature area in its own folder.
You are about to break up the app into different feature modules, each with its own concerns. Then you'll import into the main module and navigate among them.
Add heroes functionality
Follow these steps:
- Create the
src/app/heroes
folder; you'll be adding files implementing hero management there. - Delete the placeholder
hero-list.component.ts
that's in theapp
folder. - Create a new
hero-list.component.ts
undersrc/app/heroes
. - Copy into it the contents of the
app.component.ts
from theTour of Heroes: Services example code / download example . - Make a few minor but necessary changes:
- Delete the
selector
(routed components don't need them). - Delete the
. - Relabel the
to
.HEROES
- Delete the
at the bottom of the template. - Rename the
AppComponent
class toHeroListComponent
.
- Delete the
- Copy the
hero-detail.component.ts
and thehero.service.ts
files into theheroes
subfolder. - Create a (pre-routing)
heroes.module.ts
in the heroes folder that looks like this:
- import { NgModule } from '@angular/core';
- import { CommonModule } from '@angular/common';
- import { FormsModule } from '@angular/forms';
-
- import { HeroListComponent } from './hero-list.component';
- import { HeroDetailComponent } from './hero-detail.component';
-
- import { HeroService } from './hero.service';
-
- @NgModule({
- imports: [
- CommonModule,
- FormsModule,
- ],
- declarations: [
- HeroListComponent,
- HeroDetailComponent
- ],
- providers: [ HeroService ]
- })
- export class HeroesModule {}
When you're done, you'll have these hero management files:
src/app/heroes
Hero feature routing requirements
The heroes feature has two interacting components, the hero list and the hero detail. The list view is self-sufficient; you navigate to it, it gets a list of heroes and displays them.
The detail view is different. It displays a particular hero. It can't know which hero to show on its own. That information must come from outside.
When the user selects a hero from the list, the app should navigate to the detail view and show that hero. You tell the detail view which hero to display by including the selected hero's id in the route URL.
Hero feature route configuration
Create a new
heroes-routing.module.ts
in the heroes
folder using the same techniques you learned while creating the AppRoutingModule
.
- import { NgModule } from '@angular/core';
- import { RouterModule, Routes } from '@angular/router';
-
- import { HeroListComponent } from './hero-list.component';
- import { HeroDetailComponent } from './hero-detail.component';
-
- const heroesRoutes: Routes = [
- { path: 'heroes', component: HeroListComponent },
- { path: 'hero/:id', component: HeroDetailComponent }
- ];
-
- @NgModule({
- imports: [
- RouterModule.forChild(heroesRoutes)
- ],
- exports: [
- RouterModule
- ]
- })
- export class HeroRoutingModule { }
Put the routing module file in the same folder as its companion module file. Here both
heroes-routing.module.ts
and heroes.module.ts
are in the same src/app/heroes
folder.
Consider giving each feature module its own route configuration file. It may seem like overkill early when the feature routes are simple. But routes have a tendency to grow more complex and consistency in patterns pays off over time.
Import the hero components from their new locations in the
src/app/heroes/
folder, define the two hero routes, and export the HeroRoutingModule
class.
Now that you have routes for the
Heroes
module, register them with the Router
via the RouterModule
almostas you did in the AppRoutingModule
.
There is a small but critical difference. In the
AppRoutingModule
, you used the static RouterModule.forRoot
method to register the routes and application level service providers. In a feature module you use the static forChild
method.
Only call
RouterModule.forRoot
in the root AppRoutingModule
(or the AppModule
if that's where you register top level application routes). In any other module, you must call the RouterModule.forChild
method to register additional routes.Add the routing module to the HeroesModule
Add the
HeroRoutingModule
to the HeroModule
just as you added AppRoutingModule
to the AppModule
.
Open
heroes.module.ts
. Import the HeroRoutingModule
token from heroes-routing.module.ts
and add it to the imports
array of the HeroesModule
. The finished HeroesModule
looks like this:
- import { NgModule } from '@angular/core';
- import { CommonModule } from '@angular/common';
- import { FormsModule } from '@angular/forms';
-
- import { HeroListComponent } from './hero-list.component';
- import { HeroDetailComponent } from './hero-detail.component';
-
- import { HeroService } from './hero.service';
-
- import { HeroRoutingModule } from './heroes-routing.module';
-
- @NgModule({
- imports: [
- CommonModule,
- FormsModule,
- HeroRoutingModule
- ],
- declarations: [
- HeroListComponent,
- HeroDetailComponent
- ],
- providers: [ HeroService ]
- })
- export class HeroesModule {}
Remove duplicate hero routes
The hero routes are currently defined in two places: in the
HeroesRoutingModule
, by way of the HeroesModule
, and in the AppRoutingModule
.
Routes provided by feature modules are combined together into their imported module's routes by the router. This allows you to continue defining the feature module routes without modifying the main route configuration.
But you don't want to define the same routes twice. Remove the
HeroListComponent
import and the /heroes
route from the app-routing.module.ts
.
Leave the default and the wildcard routes! These are concerns at the top level of the application itself.
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CrisisListComponent } from './crisis-list.component';
// import { HeroListComponent } from './hero-list.component'; // <-- delete="" line="" span="" this="">
import { PageNotFoundComponent } from './not-found.component';
const appRoutes: Routes = [
{ path: 'crisis-center', component: CrisisListComponent },
// { path: 'heroes', component: HeroListComponent }, // <-- delete="" line="" span="" this="">
{ path: '', redirectTo: '/heroes', pathMatch: 'full' },
{ path: '**', component: PageNotFoundComponent }
];
@NgModule({
imports: [
RouterModule.forRoot(
appRoutes,
{ enableTracing: true } // <-- debugging="" only="" purposes="" span="">
)
],
exports: [
RouterModule
]
})
export class AppRoutingModule {}-->-->-->
Import hero module into AppModule
The heroes feature module is ready, but the application doesn't know about the
HeroesModule
yet. Open app.module.ts
and revise it as follows.
Remove the
HeroListComponent
from the AppModule
's declarations
because it's now provided by the HeroesModule
. This is important. There can be only one owner for a declared component. In this case, the Heroes
module is the owner of the Heroes
components and is making them available to components in the AppModule
via the HeroesModule
.
As a result, the
AppModule
no longer has specific knowledge of the hero feature, its components, or its route details. You can evolve the hero feature with more components and different routes. That's a key benefit of creating a separate module for each feature area.
After these steps, the
AppModule
should look like this:
- import { NgModule } from '@angular/core';
- import { BrowserModule } from '@angular/platform-browser';
- import { FormsModule } from '@angular/forms';
-
- import { AppComponent } from './app.component';
- import { AppRoutingModule } from './app-routing.module';
- import { HeroesModule } from './heroes/heroes.module';
-
- import { CrisisListComponent } from './crisis-list.component';
- import { PageNotFoundComponent } from './not-found.component';
-
- @NgModule({
- imports: [
- BrowserModule,
- FormsModule,
- HeroesModule,
- AppRoutingModule
- ],
- declarations: [
- AppComponent,
- CrisisListComponent,
- PageNotFoundComponent
- ],
- bootstrap: [ AppComponent ]
- })
- export class AppModule { }
Module import order matters
Look at the module
imports
array. Notice that the AppRoutingModule
is last. Most importantly, it comes afterthe HeroesModule
.imports: [
BrowserModule,
FormsModule,
HeroesModule,
AppRoutingModule
],
The order of route configuration matters. The router accepts the first route that matches a navigation request path.
When all routes were in one
AppRoutingModule
, you put the default and wildcard routes last, after the /heroes
route, so that the router had a chance to match a URL to the /heroes
route before hitting the wildcard route and navigating to "Page not found".
The routes are no longer in one file. They are distributed across two modules,
AppRoutingModule
and HeroesRoutingModule
.
Each routing module augments the route configuration in the order of import. If you list
AppRoutingModule
first, the wildcard route will be registered before the hero routes. The wildcard route—which matches everyURL—will intercept the attempt to navigate to a hero route.
Reverse the routing modules and see for yourself that a click of the heroes link results in "Page not found". Learn about inspecting the runtime router configuration below.
Route definition with a parameter
Return to the
HeroesRoutingModule
and look at the route definitions again. The route to HeroDetailComponent
has a twist.{ path: 'hero/:id', component: HeroDetailComponent }
Notice the
:id
token in the path. That creates a slot in the path for a Route Parameter. In this case, the router will insert the id
of a hero into that slot.
If you tell the router to navigate to the detail component and display "Magneta", you expect a hero id to appear in the browser URL like this:
localhost:3000/hero/15
If a user enters that URL into the browser address bar, the router should recognize the pattern and go to the same "Magneta" detail view.
Embedding the route parameter token,
:id
, in the route definition path is a good choice for this scenario because the id
is required by the HeroDetailComponent
and because the value 15
in the path clearly distinguishes the route to "Magneta" from a route for some other hero.Setting the route parameters in the list view
After navigating to the
HeroDetailComponent
, you expect to see the details of the selected hero. You need two pieces of information: the routing path to the component and the hero's id
.
Accordingly, the link parameters array has two items: the routing path and a route parameter that specifies the
id
of the selected hero.['/hero', hero.id] // { 15 }
The router composes the destination URL from the array like this:
localhost:3000/hero/15
.
How does the target
HeroDetailComponent
learn about that id
? Don't analyze the URL. Let the router do it.
The router extracts the route parameter (
id:15
) from the URL and supplies it to the HeroDetailComponent
via the ActivatedRoute
service.Activated Route in action
import { Router, ActivatedRoute, ParamMap } from '@angular/router';
Import the
switchMap
operator because you need it later to process the Observable
route parameters.import { switchMap } from 'rxjs/operators';
As usual, you write a constructor that asks Angular to inject services that the component requires and reference them as private variables.
constructor(
private route: ActivatedRoute,
private router: Router,
private service: HeroService
) {}
Later, in the
ngOnInit
method, you use the ActivatedRoute
service to retrieve the parameters for the route, pull the hero id
from the parameters and retrieve the hero to display.ngOnInit() {
this.hero$ = this.route.paramMap.pipe(
switchMap((params: ParamMap) =>
this.service.getHero(params.get('id')))
);
}
The
paramMap
processing is a bit tricky. When the map changes, you get()
the id
parameter from the changed parameters.
Then you tell the
HeroService
to fetch the hero with that id
and return the result of the HeroService
request.
You might think to use the RxJS
map
operator. But the HeroService
returns an Observable
. So you flatten the Observable
with the switchMap
operator instead.
The
switchMap
operator also cancels previous in-flight requests. If the user re-navigates to this route with a new id
while the HeroService
is still retrieving the old id
, switchMap
discards that old request and returns the hero for the new id
.
The observable
Subscription
will be handled by the AsyncPipe
and the component's hero
property will be (re)set with the retrieved hero.ParamMap API
The
ParamMap
API is inspired by the URLSearchParams interface. It provides methods to handle parameter access for both route parameters (paramMap
) and query parameters (queryParamMap
).Member | Description |
---|---|
has(name) |
Returns
true if the parameter name is in the map of parameters. |
get(name) |
Returns the parameter name value (a
string ) if present, or null if the parameter name is not in the map. Returns the first element if the parameter value is actually an array of values. |
getAll(name) |
Returns a
string array of the parameter name value if found, or an empty array if the parameter name value is not in the map. Use getAll when a single parameter could have multiple values. |
keys |
Returns a
string array of all parameter names in the map. |
Observable paramMap and component reuse
In this example, you retrieve the route parameter map from an
Observable
. That implies that the route parameter map can change during the lifetime of this component.
They might. By default, the router re-uses a component instance when it re-navigates to the same component type without visiting a different component first. The route parameters could change each time.
Suppose a parent component navigation bar had "forward" and "back" buttons that scrolled through the list of heroes. Each click navigated imperatively to the
HeroDetailComponent
with the next or previous id
.
You don't want the router to remove the current
HeroDetailComponent
instance from the DOM only to re-create it for the next id
. That could be visibly jarring. Better to simply re-use the same component instance and update the parameter.
Unfortunately,
ngOnInit
is only called once per component instantiation. You need a way to detect when the route parameters change from within the same instance. The observable paramMap
property handles that beautifully.
When subscribing to an observable in a component, you almost always arrange to unsubscribe when the component is destroyed.
There are a few exceptional observables where this is not necessary. The
ActivatedRoute
observables are among the exceptions.
The
ActivatedRoute
and its observables are insulated from the Router
itself. The Router
destroys a routed component when it is no longer needed and the injected ActivatedRoute
dies with it.
Feel free to unsubscribe anyway. It is harmless and never a bad practice.
Snapshot: the no-observable alternative
This application won't re-use the
HeroDetailComponent
. The user always returns to the hero list to select another hero to view. There's no way to navigate from one hero detail to another hero detail without visiting the list component in between. Therefore, the router creates a new HeroDetailComponent
instance every time.
When you know for certain that a
HeroDetailComponent
instance will never, never, ever be re-used, you can simplify the code with the snapshot.
The
route.snapshot
provides the initial value of the route parameter map. You can access the parameters directly without subscribing or adding observable operators. It's much simpler to write and read:ngOnInit() {
let id = this.route.snapshot.paramMap.get('id');
this.hero$ = this.service.getHero(id);
}
Remember: you only get the initial value of the parameter map with this technique. Stick with the observable
paramMap
approach if there's even a chance that the router could re-use the component. This sample stays with the observable paramMap
strategy just in case.Navigating back to the list component
The
HeroDetailComponent
has a "Back" button wired to its gotoHeroes
method that navigates imperatively back to the HeroListComponent
.
The router
navigate
method takes the same one-item link parameters array that you can bind to a [routerLink]
directive. It holds the path to the HeroListComponent
:gotoHeroes() {
this.router.navigate(['/heroes']);
}
Route Parameters: Required or optional?
Use route parameters to specify a required parameter value within the route URL as you do when navigating to the
HeroDetailComponent
in order to view the hero with id 15:localhost:3000/hero/15
You can also add optional information to a route request. For example, when returning to the heroes list from the hero detail view, it would be nice if the viewed hero was preselected in the list.
You'll implement this feature in a moment by including the viewed hero's
id
in the URL as an optional parameter when returning from the HeroDetailComponent
.
Optional information takes other forms. Search criteria are often loosely structured, e.g.,
name='wind*'
. Multiple values are common—after='12/31/2015' & before='1/1/2017'
—in no particular order—before='1/1/2017' & after='12/31/2015'
— in a variety of formats—during='currentYear'
.
These kinds of parameters don't fit easily in a URL path. Even if you could define a suitable URL token scheme, doing so greatly complicates the pattern matching required to translate an incoming URL to a named route.
Optional parameters are the ideal vehicle for conveying arbitrarily complex information during navigation. Optional parameters aren't involved in pattern matching and afford flexibility of expression.
The router supports navigation with optional parameters as well as required route parameters. Define optional parameters in a separate object after you define the required route parameters.
In general, prefer a required route parameter when the value is mandatory (for example, if necessary to distinguish one route path from another); prefer an optional parameter when the value is optional, complex, and/or multivariate.
Heroes list: optionally selecting a hero
When navigating to the
HeroDetailComponent
you specified the required id
of the hero-to-edit in the route parameter and made it the second item of the link parameters array.['/hero', hero.id] // { 15 }
The router embedded the
id
value in the navigation URL because you had defined it as a route parameter with an :id
placeholder token in the route path
:{ path: 'hero/:id', component: HeroDetailComponent }
When the user clicks the back button, the
HeroDetailComponent
constructs another link parameters arraywhich it uses to navigate back to the HeroListComponent
.gotoHeroes() {
this.router.navigate(['/heroes']);
}
This array lacks a route parameter because you had no reason to send information to the
HeroListComponent
.
Now you have a reason. You'd like to send the id of the current hero with the navigation request so that the
HeroListComponent
can highlight that hero in its list. This is a nice-to-have feature; the list will display perfectly well without it.
Send the
id
with an object that contains an optional id
parameter. For demonstration purposes, there's an extra junk parameter (foo
) in the object that the HeroListComponent
should ignore. Here's the revised navigation statement:gotoHeroes(hero: Hero) {
let heroId = hero ? hero.id : null;
// Pass along the hero id if available
// so that the HeroList component can select that hero.
// Include a junk 'foo' property for fun.
this.router.navigate(['/heroes', { id: heroId, foo: 'foo' }]);
}
The application still works. Clicking "back" returns to the hero list view.
Look at the browser address bar.
It should look something like this, depending on where you run it:
localhost:3000/heroes;id=15;foo=foo
The
id
value appears in the URL as (;id=15;foo=foo
), not in the URL path. The path for the "Heroes" route doesn't have an :id
token.
The optional route parameters are not separated by "?" and "&" as they would be in the URL query string. They are separated by semicolons ";" This is matrix URL notation—something you may not have seen before.
Matrix URL notation is an idea first introduced in a 1996 proposal by the founder of the web, Tim Berners-Lee.
Although matrix notation never made it into the HTML standard, it is legal and it became popular among browser routing systems as a way to isolate parameters belonging to parent and child routes. The Router is such a system and provides support for the matrix notation across browsers.
The syntax may seem strange to you but users are unlikely to notice or care as long as the URL can be emailed and pasted into a browser address bar as this one can.
Route parameters in the ActivatedRoute service
The list of heroes is unchanged. No hero row is highlighted.
The live example / download example does highlight the selected row because it demonstrates the final state of the application which includes the steps you're about to cover. At the moment this guide is describing the state of affairs prior to those steps.
The
HeroListComponent
isn't expecting any parameters at all and wouldn't know what to do with them. You can change that.
Previously, when navigating from the
HeroListComponent
to the HeroDetailComponent
, you subscribed to the route parameter map Observable
and made it available to the HeroDetailComponent
in the ActivatedRoute
service. You injected that service in the constructor of the HeroDetailComponent
.
This time you'll be navigating in the opposite direction, from the
HeroDetailComponent
to the HeroListComponent
.
First you extend the router import statement to include the
ActivatedRoute
service symbol:import { ActivatedRoute, ParamMap } from '@angular/router';
Import the
switchMap
operator to perform an operation on the Observable
of route parameter map.import { Observable } from 'rxjs';
import { switchMap } from 'rxjs/operators';
Then you inject the
ActivatedRoute
in the HeroListComponent
constructor.export class HeroListComponent implements OnInit {
heroes$: Observable<Hero[]>;
private selectedId: number;
constructor(
private service: HeroService,
private route: ActivatedRoute
) {}
ngOnInit() {
this.heroes$ = this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
// (+) before `params.get()` turns the string into a number
this.selectedId = +params.get('id');
return this.service.getHeroes();
})
);
}
}
The
ActivatedRoute.paramMap
property is an Observable
map of route parameters. The paramMap
emits a new map of values that includes id
when the user navigates to the component. In ngOnInit
you subscribe to those values, set the selectedId
, and get the heroes.
Update the template with a class binding. The binding adds the
selected
CSS class when the comparison returns true
and removes it when false
. Look for it within the repeated
tag as shown here:template: `
HEROES
- ngFor="let hero of heroes$ | async"
[class.selected]="hero.id === selectedId">
<a [routerLink]="['/hero', hero.id]">
{{ hero.id }}{{ hero.name }}
</a>
`
When the user navigates from the heroes list to the "Magneta" hero and back, "Magneta" appears selected:
The optional
foo
route parameter is harmless and continues to be ignored.Adding animations to the routed component
The heroes feature module is almost complete, but what is a feature without some smooth transitions?
This section shows you how to add some animations to the
HeroDetailComponent
.
First import
BrowserAnimationsModule
:import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({
imports: [
BrowserAnimationsModule
Create an
animations.ts
file in the root src/app/
folder. The contents look like this:import { animate, state, style, transition, trigger } from '@angular/animations';
// Component transition animations
export const slideInDownAnimation =
trigger('routeAnimation', [
state('*',
style({
opacity: 1,
transform: 'translateX(0)'
})
),
transition(':enter', [
style({
opacity: 0,
transform: 'translateX(-100%)'
}),
animate('0.2s ease-in')
]),
transition(':leave', [
animate('0.5s ease-out', style({
opacity: 0,
transform: 'translateY(100%)'
}))
])
]);
This file does the following:
- Imports the animation symbols that build the animation triggers, control state, and manage transitions between states.
- Exports a constant named
slideInDownAnimation
set to an animation trigger namedrouteAnimation
; animated components will refer to this name. - Specifies the wildcard state ,
*
, that matches any animation state that the route component is in. - Defines two transitions, one to ease the component in from the left of the screen as it enters the application view (
:enter
), the other to animate the component down as it leaves the application view (:leave
).
You could create more triggers with different transitions for other route components. This trigger is sufficient for the current milestone.
Back in the
HeroDetailComponent
, import the slideInDownAnimation
from './animations.ts
. Add the HostBinding
decorator to the imports from @angular/core
; you'll need it in a moment.
Then add three
@HostBinding
properties to the class to set the animation and styles for the route component's element.@HostBinding('@routeAnimation') routeAnimation = true;
@HostBinding('style.display') display = 'block';
@HostBinding('style.position') position = 'absolute';
The
'@routeAnimation'
passed to the first @HostBinding
matches the name of the slideInDownAnimation
trigger, routeAnimation
. Set the routeAnimation
property to true
because you only care about the :enter
and :leave
states.
The other two
@HostBinding
properties style the display and position of the component.
The
HeroDetailComponent
will ease in from the left when routed to and will slide down when navigating away.
Applying route animations to individual components works for a simple demo, but in a real life app, it is better to animate routes based on route paths.
Milestone 3 wrap up
You've learned how to do the following:
- Organize the app into feature areas.
- Navigate imperatively from one component to another.
- Pass information along in route parameters and subscribe to them in the component.
- Import the feature area NgModule into the
AppModule
. - Apply animations to the route component.
After these changes, the folder structure looks like this:
router-sample
Here are the relevant files for this version of the sample application.
Comments
Post a Comment