Ir al contenido

Tutorial: Asociar entidades desde el front con un selector

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 POST sin cuerpo.
  • Mostrar al usuario el mensaje que escribió el back cuando la asociación falla (400 o 404), 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.


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.


Dos métodos nuevos en ArtistaService. El primero devuelve el httpResource con las opciones del selector, y el segundo hace el POST:

artist.service.ts
// 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 /albumes se tipa como AlbumCompleto[]. Todo GET del back devuelve el DTOCompleto, así que cada álbum de la lista ya trae sus canciones. El modelo Album, sin canciones, es solo para los álbumes que vienen anidados dentro de un artista.
  • albumesResource() es un método, no una propiedad. Un httpResource guardado 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 post es null. Es la forma de decir “sin cuerpo”. La teoría lo explica en POST sin cuerpo: se comprobó que la petición sale sin Content-Type y 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.

Crea artist-add-album/artist-add-album.ts:

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:

artist-add-album.html
<div class="container py-4">
<a [routerLink]="['/artistas', id()]">&larr; 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 por error() y no lee value(), que lanza una excepción cuando el recurso está en error.
  • [ngValue] en cada <option>. Con [ngValue]="album.id" el control recibe el id tal como es, un number. Con [value] recibiría un string ("4") y el POST tendría que convertirlo.
  • La primera opción vale null. Representa “todavía no se eligió nada”, y como Validators.required considera inválido un null, el botón queda deshabilitado hasta que se elija un álbum.
  • id = input.required<string>(). La ruta siempre entrega el id, 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 de reload() es para la lista de artistas, que guarda los álbumes de cada uno.
  • mensajeDe lee message. El back responde un cuerpo con status, message y timestamp, como el del manejador de excepciones del Release 7. Ese message es el texto que escribió quien programó la regla; mostrarlo es mejor que un “ocurrió un error”. Está explicado en Qué contiene el error.

El componente se registra en app.routes.ts:

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):

artist-detalle.html
@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>
}

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.


  • Al pulsar el botón la página se recarga y no pasa nada. Falta el [formGroup] del <form> (sección 4).
  • El POST sale con un cuerpo o con Content-Type. Se pasó un objeto como segundo argumento. Para un POST sin cuerpo se pasa null.
  • El selector aparece vacío y nadie sabe por qué. Se dibujó el <select> sin distinguir carga, error y lista vacía.
  • El POST recibe "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: navigate va dentro de next, no después de subscribe. Y si la lista muestra datos viejos, falta el reload() de artistasResource.
  • El mensaje del back no aparece, solo el genérico. El cuerpo del error no tiene el campo message que lee mensajeDe: revisa cómo responde tu manejador de excepciones.

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 FormGroup y que las opciones usen [ngValue].
  • Que el POST salga 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.

Al final de este tutorial tienes:

  • albumesResource() y asociarAlbum() en el servicio.
  • Un componente ArtistAddAlbum con un selector de cuatro estados, un POST sin cuerpo y el mensaje del back cuando falla.
  • La ruta artistas/:id/add-album y 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.