Routing to a Detail Page
We’re making some great progress now. We have the ability to create a todo,
it can be stored as part of our application’s “state” in memory using our
service, and we can display todos stored in that service in our HomeComponent.
At the moment, we are only displaying the todos title property. Now we are
going to create a detail page so that we can click on any of our todos to view
its full details in another page. As I pointed out before, this is a bit of
a silly example because there is such a small amount of information to see that
we should probably just have it all on the home page. However, we are just doing
this as an exercise in setting up a master/detail pattern.
Create the Detail Component
Let’s start by creating our DetailComponent. Like our HomeComponent, this
component is going to be routed to (not used inside of another components
template). Because we are routing to this component, we will consider it as
another “feature” in our application and it will get its own folder (and its own
route in the main routing file).
import { Component } from '@angular/core';
@Component({ selector: 'app-detail', template: ` <h2>Detail</h2> `,})export default class DetailComponent {}import { Routes } from '@angular/router';
export const routes: Routes = [ { path: 'home', loadComponent: () => import('./home/home.component'), }, { path: 'detail/:id', loadComponent: () => import('./detail/detail.component'), }, { path: '', redirectTo: 'home', pathMatch: 'full', },];We are routing to our DetailComponent now, but notice that we have specified
an :id as a parameter. If we navigate to detail/12 the router will navigate
to DetailComponent and the id value of 12 will be available to it to use.
Add an id property to Todo
Our todos don’t actually have an id property yet, and it’s going to be a bit
hard to navigate to them using their id when they do not have one. Let’s add
that now.
export interface Todo { id: string; title: string; description: string;}
export type CreateTodo = Omit<Todo, 'id'>;We’re getting a bit fancy with TypeScript now. We have been creating todos
already under the assumption that all a todo has is a title and a string but
now we have changed that by adding an id.
However, we don’t want the user to supply the id when creating the todo. We
want that to do that automatically. That means that when a user is creating
a todo we only want them to have to supply the title and description. But,
when we are displaying the todos in the application we will need to also include
the id.
To deal with this, now have two different types: Todo and CreateTodo. We are
using the Omit utility type from TypeScript which allows us to use an existing
type (in this case Todo) and then remove specific properties from it (in this
case id) in order to create a new type.
Now the following structure data would satisfy a Todo:
{ id: '1', title: 'hello', description: 'world'}and the following structure would satisfy a CreateTodo:
{ title: 'hello', description: 'world'}Modifying the Todo type like this is going to cause some errors — you will see
these in your code editor and also in the terminal where you are running ng serve. Let’s deal with that.
todoSubmitted = output<CreateTodo>();Our form no longer emits data that satisfies the Todo type, as it is only emitting a title and description value.
This change will cause more problems.
import { Injectable, signal } from '@angular/core';import { CreateTodo, Todo } from '../interfaces/todo';
@Injectable({ providedIn: 'root',})export class TodoService { // We only want this class to be able to // update the signal (# makes it private) #todos = signal<Todo[]>([]);
// This can be read publicly todos = this.#todos.asReadonly();
addTodo(todo: CreateTodo) { this.#todos.update((todos) => [...todos, todo]); }}This will still cause problems because now when we are trying to update our
signal we are trying to add something of type CreateTodo to an array of
elements of the type Todo. There is one more thing we need to do.
import { Injectable, signal } from '@angular/core';import { CreateTodo, Todo } from '../interfaces/todo';
@Injectable({ providedIn: 'root',})export class TodoService { // We only want this class to be able to // update the signal (# makes it private) #todos = signal<Todo[]>([]);
// This can be read publicly todos = this.#todos.asReadonly();
addTodo(todo: CreateTodo) { this.#todos.update((todos) => [ ...todos, { ...todo, id: Date.now().toString() }, ]); }}Now instead of just adding the todo to the array directly, we create a new
object using the existing title and description by spreading the todo
object, and then we also add an id property onto it using the current time to
serve as our unique value.
Now that we have a real id you can also update the track statement in the
TodoListComponent if you want:
@for (todo of todos; track todo.id){Navigate to the Detail Component
Now, when we navigate to our detail route, we can pass in the id for the
specific todo we want to view as part of the route.
<a routerLink="/detail/{{ todo.id }}">{{ todo.title }}</a>The routerLink won’t work by default here — see if you can figure out why and
how to fix it before viewing the answer.
Click here to reveal solution
Solution
You will need to add the RouterLink import from @angular/router:
@Component({ selector: 'app-todo-list', template: ` <ul> @for (todo of todos(); track todo.id){ <li> <a routerLink="/detail/{{ todo.id }}">{{ todo.title }}</a> </li> } @empty { <li>Nothing to do!</li> } </ul> `, imports: [RouterLink],})Use the Route Param on the Detail Component
Once we navigate to the detail component, we need to take that id that we
passed in as part of the route and grab all of the information for the todo that
matches that id.
import { Component, computed, inject } from '@angular/core';import { toSignal } from '@angular/core/rxjs-interop';import { TodoService } from '../shared/data-access/todo.service';import { ActivatedRoute } from '@angular/router';
@Component({ selector: 'app-detail', template: ` <h2>Detail</h2> `,})export default class DetailComponent { private route = inject(ActivatedRoute); private todoService = inject(TodoService);
private paramMap = toSignal(this.route.paramMap);
todo = computed(() => this.todoService .todos() .find((todo) => todo.id === this.paramMap()?.get('id')) );}First, we need to get the id somehow. That is why we are injecting
ActivatedRoute — this gives us information about the currently activated
route. This information includes an observable stream called paramMap which
contains a map of all the parameters in the URL (including our id).
Since we are primarily working with signals here, we convert this observable
stream into a signal by using the toSignal method from
@angular/core/rxjs-interop.
Now we can create a computed signal from the signal that contains all of
our todos in the TodoService and our paramMap signal that contains the id.
We use the find method on the array of todos to find the one specific todo we
are interested in.
Now we can use that in that computed signal in the template.
@if (todo(); as todo){ <h2>{{ todo.title }}</h2> <p>{{ todo.description }}</p> } @else { <p>Could not find todo...</p> }This is mostly pretty straight-forward: if there is a matching todo we display its information, otherwise we display a message saying we could not find it.
However, we are also creating an alias:
todo(); as todoThis serves two purposes for us. One is that it will allow us to just reference
the todo value as todo within the if block rather than having to write
todo() every time. The other purpose is that todo will have a narrowed type.
Our todo() signal could actually either be a Todo or it could be undefined
if there were no todo with a matching id. Since our if block is checking
that it is defined, if we create an alias, TypeScript will know that todo is
definitely defined within that if block.
Now we can view an individual todo on the detail page! It’s still ugly though, so in the next lesson we are going to make it a bit prettier.