Tutorial: Asociar entidades desde el front con un selector
1. Objetivos de aprendizaje
Sección titulada «1. Objetivos de aprendizaje»Al terminar este tutorial vas a ser capaz de:
- Cargar las opciones de un selector desde el back y distinguir cuatro estados: cargando, error, sin opciones y con opciones.
- Llamar al endpoint de asociación con un
POSTsin cuerpo. - Mostrar al usuario el mensaje que escribió el back cuando la asociación falla
(
400o404), y uno propio cuando no hay conexión. - Navegar de vuelta al detalle cuando la asociación se crea, y ver allí el resultado.
- Registrar la ruta del componente y enlazarla desde el detalle del recurso dueño.
Prerrequisitos: el tutorial de
el detalle de un recurso por ruta (ya
tienes ArtistDetalle con el id de la ruta), el de
rutas y menú y un back con el endpoint de
asociación del Release 7. Las ideas están en la teoría de
Formularios Reactivos
y de HttpResource y HttpClient;
este tutorial las aplica.
2. De dónde partes
Sección titulada «2. De dónde partes»En el Release 7 el back expuso la asociación entre artistas y álbumes:
| Operación | Endpoint | Respuestas |
|---|---|---|
| Asociar un álbum a un artista | POST /artistas/{artistaId}/albumes/{albumId} |
éxito; 400 si el artista ya tiene ese álbum; 404 si alguno no existe |
El componente que vas a escribir es el del lado dueño de la asociación: el artista.
Muestra un selector con todos los álbumes, y al elegir uno y pulsar el botón llama a ese
endpoint. Los dos recursos ya existen: la asociación solo los relaciona, por eso el
POST no lleva cuerpo.
3. El servicio: las opciones y el POST
Sección titulada «3. El servicio: las opciones y el POST»Dos métodos nuevos en ArtistaService. El primero devuelve el httpResource con las
opciones del selector, y el segundo hace el POST:
// Las opciones del selector de asociación: GET /albumes. Se crea desde el componente que// lo usa, para pedir la lista solo cuando se abre esa vista.albumesResource(): HttpResourceRef<AlbumCompleto[] | undefined> { return httpResource<AlbumCompleto[]>(() => environment.ALBUMES_URL);}
// POST /artistas/{artistaId}/albumes/{albumId}: asocia un álbum que ya existe a un// artista. Va sin cuerpo (null): los dos ids viajan en la URL.asociarAlbum(artistaId: string, albumId: number): Observable<unknown> { return this.http.post<unknown>(`${environment.ARTISTAS_URL}/${artistaId}/albumes/${albumId}`, null);}Fíjate en las piezas:
GET /albumesse tipa comoAlbumCompleto[]. TodoGETdel back devuelve elDTOCompleto, así que cada álbum de la lista ya trae suscanciones. El modeloAlbum, sin canciones, es solo para los álbumes que vienen anidados dentro de un artista.albumesResource()es un método, no una propiedad. UnhttpResourceguardado como propiedad del servicio pide la lista en cuanto el servicio se crea, es decir, al arrancar la aplicación. Como método, el recurso nace cuando el componente de asociación lo llama, y la lista de álbumes solo se pide al abrir esa vista.- El segundo argumento del
postesnull. Es la forma de decir “sin cuerpo”. La teoría lo explica en POST sin cuerpo: se comprobó que la petición sale sinContent-Typey con cero bytes. - La respuesta se ignora (
Observable<unknown>). Según cómo respondió tu back (el álbum, el artista con sus álbumes), el cuerpo cambia; al componente solo le importa que la asociación se haya creado, y para ver el resultado se vuelve a pedir el artista en su detalle.
4. El componente de asociación
Sección titulada «4. El componente de asociación»Crea artist-add-album/artist-add-album.ts:
import { Component, WritableSignal, inject, input, signal } from '@angular/core';import { HttpErrorResponse, HttpResourceRef } from '@angular/common/http';import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';import { Router, RouterLink } from '@angular/router';import { AlbumCompleto } from '../album-list/album';import { ArtistaService } from '../artist.service';
// La forma del cuerpo de un error del back: { status, message, timestamp }.interface ErrorDelBack { status: number; message: string;}
@Component({ imports: [ReactiveFormsModule, RouterLink], selector: 'app-artist-add-album', templateUrl: './artist-add-album.html',})export class ArtistAddAlbum { private readonly artistaService: ArtistaService = inject(ArtistaService); private readonly router: Router = inject(Router);
// El Router entrega el id de /artistas/:id/add-album. readonly id = input.required<string>();
// Las opciones del selector vienen del back: GET /albumes. readonly albumes: HttpResourceRef<AlbumCompleto[] | undefined> = this.artistaService.albumesResource();
// Sin nonNullable: un selector sin elección no tiene un valor natural del tipo del id. // El control va dentro de un FormGroup: un <form> sin [formGroup] no dispara (ngSubmit). readonly form: FormGroup<{ albumId: FormControl<number | null> }> = new FormGroup({ albumId: new FormControl<number | null>(null, { validators: [Validators.required] }), });
readonly guardando: WritableSignal<boolean> = signal<boolean>(false); readonly mensajeError: WritableSignal<string | undefined> = signal<string | undefined>(undefined);
asociar(): void { const albumId: number | null = this.form.controls.albumId.value; if (albumId === null) return;
this.guardando.set(true); this.mensajeError.set(undefined); this.artistaService.asociarAlbum(this.id(), albumId).subscribe({ next: (): void => { this.guardando.set(false); // La lista de artistas trae los álbumes de cada uno: se vuelve a pedir. this.artistaService.artistasResource.reload(); // El detalle por ruta pide el artista de nuevo y muestra el álbum recién asociado. this.router.navigate(['/artistas', this.id()]); }, error: (error: HttpErrorResponse): void => { this.guardando.set(false); this.mensajeError.set(this.mensajeDe(error)); }, }); }
// El mensaje que escribió quien programó la regla en el back; si no llegó (status 0: // servidor caído o CORS), uno propio. private mensajeDe(error: HttpErrorResponse): string { if (error.status === 0) { return 'No se pudo conectar con el servidor.'; } const cuerpo: ErrorDelBack | null = error.error as ErrorDelBack | null; return cuerpo?.message ?? 'No se pudo asociar el álbum.'; }}Y la plantilla artist-add-album.html:
<div class="container py-4"> <a [routerLink]="['/artistas', id()]">← Volver al artista</a> <h1 class="mt-3 mb-4">Agregar un álbum</h1>
@if (albumes.isLoading()) { <p>Cargando álbumes…</p> } @else if (albumes.error()) { <p class="text-danger">No se pudieron cargar los álbumes.</p> } @else if (albumes.hasValue() && albumes.value().length === 0) { <p>No hay álbumes para elegir.</p> } @else { <form [formGroup]="form" (ngSubmit)="asociar()" style="max-width: 480px;"> <label for="albumId" class="form-label">Álbum</label> <select id="albumId" class="form-select" formControlName="albumId"> <option [ngValue]="null">Elige un álbum</option> @for (album of albumes.value(); track album.id) { <option [ngValue]="album.id">{{ album.titulo }}</option> } </select>
<button type="submit" class="btn btn-primary mt-3" [disabled]="form.invalid || guardando()"> @if (guardando()) { Asociando… } @else { Asociar álbum } </button> </form>
@if (mensajeError(); as mensaje) { <p class="text-danger mt-3">{{ mensaje }}</p> } }</div>Las piezas, una por una:
- Cuatro estados, no uno. Un
<select>sin opciones se ve igual si la carga está en curso, si falló o si el back no tiene álbumes. Cada caso tiene su mensaje. La rama de error pregunta porerror()y no leevalue(), que lanza una excepción cuando el recurso está en error. [ngValue]en cada<option>. Con[ngValue]="album.id"el control recibe elidtal como es, unnumber. Con[value]recibiría unstring("4") y elPOSTtendría que convertirlo.- La primera opción vale
null. Representa “todavía no se eligió nada”, y comoValidators.requiredconsidera inválido unnull, el botón queda deshabilitado hasta que se elija un álbum. id = input.required<string>(). La ruta siempre entrega elid, así que aquí sí es obligatorio. Llega como texto, y así se usa en la URL.- Después de asociar,
navigate. El detalle por ruta (/artistas/:id) pide el artista de nuevo al crearse, y por eso muestra el álbum que acabas de asociar sin que hagas nada más. La línea dereload()es para la lista de artistas, que guarda los álbumes de cada uno. mensajeDeleemessage. El back responde un cuerpo constatus,messageytimestamp, como el del manejador de excepciones del Release 7. Esemessagees el texto que escribió quien programó la regla; mostrarlo es mejor que un “ocurrió un error”. Está explicado en Qué contiene el error.
5. La ruta y el enlace desde el detalle
Sección titulada «5. La ruta y el enlace desde el detalle»El componente se registra en app.routes.ts:
import { ArtistAddAlbum } from './artist-add-album/artist-add-album';
{ path: 'artistas/:id/add-album', component: ArtistAddAlbum, title: 'Agregar álbum' },El id llega al componente por la misma vía del tutorial anterior
(withComponentInputBinding()). Y se enlaza desde el detalle del artista, solo cuando el
detalle viene de una ruta (cuando hay id):
@if (!id()) { <a [routerLink]="['/artistas', artista.id]">Abrir en su propia página</a>} @else { <a class="btn btn-outline-primary btn-sm" [routerLink]="['/artistas', artista.id, 'add-album']"> Agregar un álbum </a>}6. Comprobar en el navegador
Sección titulada «6. Comprobar en el navegador»Con el back y el front andando, prueba estas situaciones. Cada una se comprobó en un navegador con un back real:
| Situación | Qué ocurre |
|---|---|
Entrar a /artistas/1/add-album |
El selector con todos los álbumes y la opción “Elige un álbum”; el botón está deshabilitado |
| Elegir un álbum y pulsar el botón | Sale un POST a /artistas/1/albumes/4, sin cuerpo y sin Content-Type; la URL pasa a /artistas/1 y el artista muestra un álbum más |
| Elegir un álbum que el artista ya tiene | El back responde 400; la pantalla se queda y muestra “El álbum ya está asociado al artista” |
Entrar a /artistas/9999/add-album y asociar |
El back responde 404; se muestra “El artista no existe” |
| El back no tiene álbumes | “No hay álbumes para elegir.”, sin selector |
| Falla la carga de los álbumes | “No se pudieron cargar los álbumes.”, sin selector |
| La carga es lenta | “Cargando álbumes…”, sin selector |
| Se cae la conexión al asociar | “No se pudo conectar con el servidor.” |
El caso de error al cargar lo puedes provocar bloqueando la petición GET /albumes con
las herramientas del navegador (pestaña Red), y el de lista vacía levantando un back sin
álbumes.
7. Errores frecuentes
Sección titulada «7. Errores frecuentes»- Al pulsar el botón la página se recarga y no pasa nada. Falta el
[formGroup]del<form>(sección 4). - El
POSTsale con un cuerpo o conContent-Type. Se pasó un objeto como segundo argumento. Para unPOSTsin cuerpo se pasanull. - El selector aparece vacío y nadie sabe por qué. Se dibujó el
<select>sin distinguir carga, error y lista vacía. - El
POSTrecibe"4"como id. Las opciones usan[value]en vez de[ngValue]. - El navegador bloquea el
POST. El back no permite el origen del front para ese verbo (CORS). Aparece en la consola del navegador, no en Postman. - Después de asociar, el detalle sigue mostrando los álbumes de antes. La navegación
ocurrió antes de que terminara el
POST:navigateva dentro denext, no después desubscribe. Y si la lista muestra datos viejos, falta elreload()deartistasResource. - El mensaje del back no aparece, solo el genérico. El cuerpo del error no tiene el
campo
messageque leemensajeDe: revisa cómo responde tu manejador de excepciones.
8. Con ayuda de la IA
Sección titulada «8. Con ayuda de la IA»Los componentes de asociación de las demás entidades son análogos a este: se puede pedir a la IA que los genere a partir de él, dándole el nombre de las dos entidades y el endpoint.
Lo que debes revisar tú:
- Que el selector distinga los cuatro estados.
- Que el control vaya en un
FormGroupy que las opciones usen[ngValue]. - Que el
POSTsalga sin cuerpo y que el mensaje que se muestre sea el del back. - Que la navegación ocurra dentro de
next, y que lleve al detalle correcto.
¿Dónde quedaste?
Sección titulada «¿Dónde quedaste?»Al final de este tutorial tienes:
albumesResource()yasociarAlbum()en el servicio.- Un componente
ArtistAddAlbumcon un selector de cuatro estados, unPOSTsin cuerpo y el mensaje del back cuando falla. - La ruta
artistas/:id/add-albumy un enlace desde el detalle del artista.
Lo que viene después, siguiendo el orden de Release 8:
- Las pruebas del front con Vitest, entre ellas la del componente de asociación.