Tutorial: El detalle de un recurso por ruta
1. Objetivos de aprendizaje
Sección titulada «1. Objetivos de aprendizaje»Al terminar este tutorial vas a ser capaz de:
- Recibir el
idde una ruta en un componente coninput()ywithComponentInputBinding(). - Pedir al back el recurso con ese
iddesde un método del servicio que devuelve unhttpResource, sin pedir nada mientras no hayaid. - Hacer que el mismo componente de detalle sirva para el detalle en memoria y para el detalle por ruta, con una sola fuente de lo que se muestra.
- Mostrar los estados de carga, de error y de 404 del detalle.
- Mostrar un álbum con sus canciones desde
GET /albumes/{id}, porque la lista de artistas no las trae. - Explicar por qué en
app.routes.tsla rutaartistas/nuevova antes deartistas/:id.
Prerrequisitos: el tutorial de
rutas y menú con el Router (ya tienes
app.routes.ts, provideRouter y el menú) y un back con GET /artistas/{id} y
GET /albumes/{id}, como quedó en el Release 7. La idea de fondo está en la teoría
de Enrutamiento y Angular Router, en
Dos tipos de detalle y en
El detalle por ruta, completo:
este tutorial la aplica y no la repite.
2. De dónde partes
Sección titulada «2. De dónde partes»Hoy el detalle de un artista es el del Release 4: la lista trae los artistas, un clic
guarda el elegido en una signal del servicio y ArtistDetalle lo muestra, sin pedir
nada al back. Tiene dos límites que el Router permite resolver:
- No tiene URL propia: no se puede enviar un enlace al detalle de un artista, y al recargar la página el detalle desaparece.
- Los álbumes de un artista llegan sin canciones. Para mostrarlas hay que pedir el
álbum al back con su
id:GET /albumes/{id}responde el álbum con la lista de canciones.
Vas a agregar dos rutas, y cada una resuelve uno de esos límites:
| URL | Vista | Qué pide al back |
|---|---|---|
/artistas/:id |
ArtistDetalle |
GET /artistas/{id} |
/albumes/:id |
AlbumDetalle (nuevo) |
GET /albumes/{id} |
El detalle en memoria no desaparece: sigue funcionando al hacer clic en la lista.
El componente ArtistDetalle pasa a atender las dos formas.
3. Recibir el id por la ruta
Sección titulada «3. Recibir el id por la ruta»Dos cambios, uno en la configuración y otro en las rutas. Primero, el Router debe
conectar los parámetros de la URL con los input() de los componentes. Se activa con
withComponentInputBinding() en provideRouter:
import { provideRouter, withComponentInputBinding } from '@angular/router';
// dentro de providers:provideRouter(routes, withComponentInputBinding()),Y en app.routes.ts agrega la ruta del detalle del artista, después de
artistas/nuevo:
import { ArtistDetalle } from './artist-detalle/artist-detalle';
// dentro de routes, en este orden:{ path: 'artistas', component: ArtistList, title: 'Artistas' },{ path: 'artistas/nuevo', component: ArtistCreate, title: 'Nuevo artista' },{ path: 'artistas/:id', component: ArtistDetalle, title: 'Artista' },El orden importa. El Router recorre las rutas en el orden en que están escritas y
se queda con la primera que coincide, y :id coincide con cualquier texto, incluso
con nuevo. Se comprobó cambiando el orden: con artistas/:id antes de
artistas/nuevo, abrir /artistas/nuevo ya no muestra el formulario; el Router toma
nuevo como un id, pide GET /artistas/nuevo al back y la pantalla dice “No se
pudo cargar el artista”. La regla: las rutas fijas van antes que las que tienen un
parámetro.
4. El servicio: pedir el recurso con el id
Sección titulada «4. El servicio: pedir el recurso con el id»El recurso se crea desde el servicio, que es el único que habla con la red. En
artist.service.ts agrega un método que devuelve un httpResource y cambia
seleccionar para que acepte undefined:
// Acepta undefined: el detalle por ruta vacía la selección mientras el recurso carga// o si falla, para que no se vea el artista elegido antes.seleccionar(artista: Artista | undefined): void { this._artistaSeleccionado.set(artista);}
// El detalle por ruta: GET /artistas/{id}. Con una URL `undefined` (no hay id en la// ruta) httpResource no hace la petición.artistaResource(id: () => string | undefined): HttpResourceRef<Artista | undefined> { return httpResource<Artista>(() => { const valor: string | undefined = id(); return valor ? `${environment.ARTISTAS_URL}/${valor}` : undefined; });}Fíjate en las piezas:
artistaResourcerecibe una función, no elidmismo. Así la URL se recalcula cada vez que elidcambia, como explica la teoría en Una URL que depende de un signal.- Devolver
undefineden vez de una URL le dice ahttpResourceque no pida nada. Por eso el mismo componente puede estar en la lista, donde no hayid, sin hacer ninguna petición. - El
ides unstring. El valor de un parámetro de ruta llega siempre como texto, aunque eliddel back sea un número. Para armar la URL no importa.
5. ArtistDetalle atiende las dos formas
Sección titulada «5. ArtistDetalle atiende las dos formas»El componente sigue mostrando lo que hay en la signal compartida del servicio. Lo
nuevo es que, si la ruta le entrega un id, la respuesta del back llena esa misma
signal. Todo lo que cuelga del detalle, como la lista de álbumes, sigue leyendo un
solo lugar.
import { Component, Signal, computed, effect, inject, input } from '@angular/core';import { HttpErrorResponse, HttpResourceRef } from '@angular/common/http';import { RouterLink } from '@angular/router';import { Artista } from '../album-list/album';import { ArtistaService } from '../artist.service';import { AlbumList } from '../album-list/album-list';
@Component({ imports: [AlbumList, RouterLink], selector: 'app-artist-detalle', styleUrl: './artist-detalle.css', templateUrl: './artist-detalle.html',})export class ArtistDetalle { private readonly artistaService: ArtistaService = inject(ArtistaService);
// Detalle por ruta: el Router entrega el id de /artistas/:id. Cuando el componente // está dentro de la lista (detalle en memoria) no hay ruta y el id queda undefined. readonly id = input<string>();
// Lo que se muestra sale siempre de la signal compartida: la llena el clic de la // lista o, con id, la respuesta del servidor. readonly artistaSeleccionado: Signal<Artista | undefined> = this.artistaService.artistaSeleccionado;
// Solo pide al servidor si hay id. readonly recurso: HttpResourceRef<Artista | undefined> = this.artistaService.artistaResource( (): string | undefined => this.id(), );
readonly noExiste: Signal<boolean> = computed((): boolean => { const error: unknown = this.recurso.error(); return error instanceof HttpErrorResponse && error.status === 404; });
constructor() { effect((): void => { if (!this.id()) return; // sin id, la selección la llena la lista al hacer clic this.artistaService.seleccionar(this.recurso.hasValue() ? this.recurso.value() : undefined); }); }}Y la plantilla, con los estados de carga, de error y de 404 antes de la tarjeta:
@if (recurso.isLoading()) { <p>Cargando artista…</p>}
@if (noExiste()) { <p class="text-danger">Ese artista no existe.</p>} @else if (recurso.error()) { <p class="text-danger">No se pudo cargar el artista. Intente de nuevo.</p>}
@if (artistaSeleccionado(); as artista) { <div class="card"> <!-- la tarjeta del artista que ya tenías: foto, nombre, país, nacimiento y biografía --> @if (!id()) { <a [routerLink]="['/artistas', artista.id]">Abrir en su propia página</a> } </div>}
<div class="mt-4"> <app-album-list /></div>Tres ideas explican el código:
- El
effectconvierte la respuesta en la selección. Conid, copia el recurso a la signal; sinid, no hace nada y la selección es la que dejó el clic de la lista. - Mientras carga, o si falla, la selección queda vacía. El
effectllama aseleccionar(undefined)hasta que hay valor. Sin eso, la pantalla seguiría mostrando el artista que se eligió antes. hasValue()antes devalue(). En estado de error,value()lanza una excepción. Está explicado en Leer el valor con seguridad.
El enlace “Abrir en su propia página” solo se muestra cuando no hay id, es
decir, en el detalle en memoria de la lista: dentro de la ruta apuntaría a donde ya
estás.
6. Llegar al detalle desde otras vistas
Sección titulada «6. Llegar al detalle desde otras vistas»Ya hay un enlace en el detalle en memoria. La otra forma habitual de llegar a un
artista es desde otra pantalla: en Premios, el nombre de cada ganador puede llevar
a su ficha. En premio-detalle.ts importa RouterLink y en la plantilla envuelve el
nombre del ganador:
<a [routerLink]="['/artistas', ganador.artistaId]"><strong>{{ ganador.artistaNombre }}</strong></a>['/artistas', ganador.artistaId] arma la URL /artistas/3: cada elemento del
arreglo es un segmento, y el id se agrega solo.
7. Comprobar el detalle del artista
Sección titulada «7. Comprobar el detalle del artista»Con el back y el front andando, prueba estas situaciones. Cada una se comprobó en un navegador con un back real:
| Situación | Qué muestra | ¿Pide al back? |
|---|---|---|
| Clic en un artista de la lista | El artista y sus álbumes, desde la signal. La URL sigue siendo /artistas. |
No |
| Clic en “Abrir en su propia página” | /artistas/1: el artista y sus álbumes |
Sí, GET /artistas/1 |
Abrir /artistas/2 escribiendo la URL |
El artista 2 | Sí, una petición con ese id |
Cambiar la URL de /artistas/1 a /artistas/2 sin recargar |
El artista 2, en el mismo componente | Sí, una petición nueva |
Llegar a /artistas/3 con una respuesta lenta, habiendo elegido otro artista en la lista |
“Cargando artista…” y ninguna tarjeta del artista anterior; al llegar la respuesta, el artista 3 | Sí |
/artistas/9999 |
“Ese artista no existe.” | Sí, responde 404 |
/artistas/nuevo |
El formulario de creación | No |
| Clic en un ganador desde Premios | La ficha de ese artista | Sí |
8. El detalle del álbum con sus canciones
Sección titulada «8. El detalle del álbum con sus canciones»Los álbumes que trae un artista no llevan canciones: las canciones solo vienen en
GET /albumes/{id}. Por eso el detalle del álbum necesita ir al back por su id,
y es el caso natural para una ruta.
El modelo. El back no manda genero, letra ni fechaLanzamiento en las
canciones de ese endpoint, así que en album.ts pasan a opcionales. Si los dejas
obligatorios, el modelo exige datos que nunca llegan:
export interface Cancion { id: number; titulo: string; duracion: string; // El back no los manda en las canciones de GET /albumes/{id}: por eso son opcionales. genero?: string; fotoPortadaUrl?: string; letra?: string; fechaLanzamiento?: string;}El servicio gana un segundo método, igual al del artista pero para el álbum:
// GET /albumes/{id}: el álbum con sus canciones (AlbumDTOCompleto).albumResource(id: () => string | undefined): HttpResourceRef<AlbumCompleto | undefined> { return httpResource<AlbumCompleto>(() => { const valor: string | undefined = id(); return valor ? `${environment.ALBUMES_URL}/${valor}` : undefined; });}El componente. Crea album-detalle/album-detalle.ts. Aquí no hay detalle en
memoria que conservar, así que no hace falta la signal compartida ni el effect:
el componente muestra directamente lo que responde el recurso.
import { Component, Signal, computed, inject, input } from '@angular/core';import { HttpErrorResponse, HttpResourceRef } from '@angular/common/http';import { RouterLink } from '@angular/router';import { AlbumCompleto, Cancion } from '../album-list/album';import { ArtistaService } from '../artist.service';
@Component({ imports: [RouterLink], selector: 'app-album-detalle', templateUrl: './album-detalle.html',})export class AlbumDetalle { private readonly artistaService: ArtistaService = inject(ArtistaService);
// El nombre del input debe ser igual al del parámetro de la ruta (:id). readonly id = input<string>();
// GET /albumes/{id}: el álbum con sus canciones. No pide nada mientras no haya id. readonly recurso: HttpResourceRef<AlbumCompleto | undefined> = this.artistaService.albumResource( (): string | undefined => this.id(), );
// hasValue() antes de value(): en estado de error value() lanza una excepción. readonly album: Signal<AlbumCompleto | undefined> = computed((): AlbumCompleto | undefined => this.recurso.hasValue() ? this.recurso.value() : undefined, ); readonly canciones: Signal<Cancion[]> = computed( (): Cancion[] => this.album()?.canciones ?? [], );
readonly noExiste: Signal<boolean> = computed((): boolean => { const error: unknown = this.recurso.error(); return error instanceof HttpErrorResponse && error.status === 404; });}<div class="container py-4"> <a routerLink="/artistas">← Volver a los artistas</a>
@if (recurso.isLoading()) { <p class="mt-3">Cargando el álbum…</p> }
@if (noExiste()) { <p class="mt-3 text-danger">Ese álbum no existe.</p> } @else if (recurso.error()) { <p class="mt-3 text-danger">No se pudo cargar el álbum. Intente de nuevo.</p> }
@if (album(); as album) { <h1 class="mt-3">{{ album.titulo }}</h1> <p class="text-muted">Lanzado el {{ album.fechaLanzamiento }}</p>
<h2 class="h4 mb-3">Canciones</h2> <ul class="list-group"> @for (cancion of canciones(); track cancion.id) { <li class="list-group-item d-flex justify-content-between align-items-center"> <span> <strong>{{ cancion.titulo }}</strong> @if (cancion.genero) { <br /> <small class="text-muted">{{ cancion.genero }}</small> } </span> <span class="text-muted">{{ cancion.duracion }}</span> </li> } @empty { <li class="list-group-item text-muted">Este álbum no tiene canciones.</li> } </ul> }</div>La ruta y el enlace. Registra la ruta en app.routes.ts y haz que cada tarjeta de
AlbumList sea un enlace a ella:
import { AlbumDetalle } from './album-detalle/album-detalle';
{ path: 'albumes/:id', component: AlbumDetalle, title: 'Álbum' },<a class="card h-100 text-decoration-none" [routerLink]="['/albumes', album.id]"> <!-- imagen, título y fecha del álbum, como antes --></a>AlbumList debe importar RouterLink. Ya no necesita el componente de canciones que
mostraba al lado: SongList, su plantilla y lo que el servicio guardaba para
seleccionar un álbum (seleccionarAlbum y el recurso del álbum seleccionado) se
reemplazan por esta ruta, así que puedes borrarlos.
Para comprobarlo, entra a /artistas/1 y haz clic en un álbum: la URL pasa a
/albumes/1, aparece el título y la lista de canciones, con una sola petición
GET /albumes/1. Recargar la página conserva la vista. /albumes/9999 muestra “Ese
álbum no existe.”
9. Errores frecuentes
Sección titulada «9. Errores frecuentes»- El detalle por ruta aparece vacío y no hay errores. El
input()no se llama igual que el parámetro de la ruta, o faltawithComponentInputBinding()enprovideRouter. Sin una de las dos cosas, elidquedaundefined. /artistas/nuevomuestra “No se pudo cargar el artista”. La ruta:idestá antes denuevo. Las rutas fijas van primero.- Se ve el artista anterior mientras carga el nuevo. Falta el
effect, o no vacía la selección mientras no hay valor (sección 5). - Una excepción al leer
value()cuando el back responde un error. Se leyóvalue()sin preguntar anteshasValue(). - El
404se muestra como un error genérico. No se distinguió elstatus 404ennoExiste: un error de red y un recurso que no existe necesitan mensajes distintos. - Las canciones no aparecen. Se pidió el artista pero no el álbum: la lista de
artistas trae los álbumes sin canciones, y solo
GET /albumes/{id}las devuelve.
10. Con ayuda de la IA
Sección titulada «10. Con ayuda de la IA»Se puede pedir la estructura de un componente de detalle por ruta: el input(),
el recurso y la plantilla con los estados. Dale el nombre de tu entidad, la ruta y el
endpoint.
Lo que debes revisar tú:
- Que el
input()se llame igual que el parámetro de la ruta, y que la ruta tengawithComponentInputBinding(). - Que se pregunte
hasValue()antes de leervalue(). - Que el orden de las rutas ponga las fijas antes de las que tienen parámetro.
- Que el
404tenga su propio mensaje. - Que no use
ActivatedRouteconsnapshotpara leer elid: es una foto de la URL al crear el componente y no se entera si elidcambia (la teoría lo explica).
¿Dónde quedaste?
Sección titulada «¿Dónde quedaste?»Al final de este tutorial tienes:
withComponentInputBinding()y las rutasartistas/:idyalbumes/:id, con las fijas antes que las de parámetro.- Un
ArtistDetalleque atiende el detalle en memoria y el de ruta con una sola fuente de lo que se muestra, y maneja carga, error y404. - Un
AlbumDetalleque muestra el álbum con sus canciones desdeGET /albumes/{id}. - Enlaces a esos detalles desde la lista de artistas, el detalle y Premios.
Lo que viene después, siguiendo el orden de Release 8:
- Los componentes que asocian entidades, con un selector que usa las opciones del back.