Saltar a contenido

Taller: estudiante-front

Descargar la presentación del taller (.pptx) Presentación 06 · Fundamentos de Angular (.pptx)

Esta página recorre una aplicación Angular real y completa que consume la API REST del proyecto estudiante. Todo lo que se ve en las páginas anteriores —servicios, Observables, modelos, componentes, rutas y formularios— aparece aquí funcionando contra un backend Spring Boot.

Repositorios públicos

Repositorio Qué contiene
Backend github.com/docsjaveriana/ex-estudiante API REST con Spring Boot: CRUD de usuarios (estudiantes) y de clases (cursos), validación, manejo de errores, CORS y pruebas
Frontend github.com/docsjaveriana/ui-estudiante SPA en Angular 21 que consume esa API, con los ejemplos de clase en src/app/ejemplos/
Publicado en URL
Aplicación Angular desarrolloweb.click/estudiante/
Documentación de la API (Swagger) desarrolloweb.click/api/estudiante/swagger-ui.html

Cómo leer esta página

Cada bloque enlaza al archivo exacto del repositorio. Lo ideal es tener el proyecto abierto en el editor y la aplicación corriendo, con las herramientas de desarrollo del navegador en la pestaña Network.


0. Ponerlo a correr

# Backend en http://localhost:8080
git clone https://github.com/docsjaveriana/ex-estudiante.git
cd ex-estudiante
export DB_PASSWORD='...'          # y DB_HOST, DB_PORT, DB_NAME, DB_USER si cambian
./mvnw spring-boot:run

# Frontend en http://localhost:4200
git clone https://github.com/docsjaveriana/ui-estudiante.git
cd ui-estudiante
cp .env.example .env              # BACKEND_URL=http://localhost:8080
npm install
npm start

El frontend llama directamente al backend indicado en BACKEND_URL. Por eso el backend tiene CORS habilitado (CorsConfig).

Estructura del frontend

Carpeta Responsabilidad Equivalente en Spring
models/ Describe la forma de los datos DTO (UsuarioResponse, ClaseRequest)
services/ Habla con la API usando HttpClient @Service + cliente HTTP
usuarios/, clases/ Componentes: muestran y reaccionan @RestController (puerta de entrada)
app.routes.ts Qué componente se muestra para cada URL @GetMapping("/ruta")
shared/ Piezas reutilizables Utilidades compartidas
ejemplos/ Ejemplos mínimos para clase —

1. Cómo llega un dato de la API a la pantalla

Cada pieza tiene un solo trabajo: el componente no sabe de URLs, el servicio no sabe de HTML y el modelo no hace nada, solo describe.

# Pieza Qué hace Código
1 Componente pide la lista al servicio service.list()
2 Servicio arma la petición HTTP http.get<Usuario[]>(url)
3 API Spring responde con JSON GET /api/estudiante/usuarios → 200
4 Observable entrega el dato ya tipado next: data => …
5 Signal avisa y la vista se redibuja usuarios.set(data)

El modelo: el contrato con la API

export interface Usuario {
  id: number;
  nombre: string;
  correo: string;
}

// Para crear: el id lo genera la base de datos.
export type UsuarioRequest = Omit<Usuario, 'id'>;
public class UsuarioResponse {
    private Long id;
    private String nombre;
    private String correo;
}

Los nombres de las propiedades deben coincidir exactamente con el JSON. 📄 models/usuario.model.ts

El servicio: sabe cómo hablar con la API

@Injectable({ providedIn: 'root' })
export class UsuarioService {
  private readonly http = inject(HttpClient);
  private readonly url = `${environment.apiUrl}/usuarios`;

  list(): Observable<Usuario[]> {
    return this.http.get<Usuario[]>(this.url);
  }

  create(usuario: UsuarioRequest): Observable<Usuario> {
    return this.http.post<Usuario>(this.url, usuario);
  }
  // get, update, delete …
}
Angular HTTP Spring (UsuarioController)
list() GET /api/estudiante/usuarios @GetMapping
get(7) GET /api/estudiante/usuarios/7 @GetMapping("/{id}")
create(u) POST + JSON → 201 @PostMapping + @RequestBody
update(7, u) PUT /usuarios/7 + JSON @PutMapping("/{id}")
delete(7) DELETE /usuarios/7 → 204 @DeleteMapping("/{id}")

📄 services/usuario.service.ts · Controlador: UsuarioController.java

El Observable, paso a paso

load(): void {
  this.status.set('loading');            // 1
  this.service.list()                    // 2
    .subscribe({                         // 3
      next: data => {                    // 4 · 5
        this.usuarios.set(data);
        this.status.set('success');      // 6
      },
      error: err => {                    // 7
        this.message.set(mensajeDeError(err, 'No fue posible cargar los estudiantes'));
        this.status.set('error');
      }
    });
}
  1. status = 'loading': el template muestra «Cargando…».
  2. list() crea el Observable: todavía no sale nada a la red.
  3. subscribe() dispara la petición: aparece GET /usuarios en Network.
  4. Spring responde 200 con JSON.
  5. next(data): llega un Usuario[] ya tipado y se guarda en el signal.
  6. status = 'success': Angular pinta la tabla.
  7. Si falla (backend apagado, 500…), se ejecuta error y se muestra un mensaje comprensible.

Sin subscribe no hay petición

this.service.list(); solo prepara la llamada. Comentar el .subscribe(...) y comprobar en Network que no sale nada.

Promise (fetch) Observable (HttpClient)
¿Cuándo se ejecuta? Apenas se crea Al suscribirse
Valores que entrega Uno Cero, uno o muchos
Cómo se usa .then(...) .subscribe({ next, error })
¿Se puede cancelar? No Sí, por ejemplo con switchMap

El componente: un signal decide qué se ve

@switch (status()) {
  @case ('loading') { <p>Cargando…</p> }
  @case ('error') {
    <p>{{ message() }}</p>
    <button (click)="load()">Reintentar</button>
  }
  @case ('success') {
    @for (u of usuarios(); track u.id) {
      <td>{{ u.nombre }}</td>
    } @empty { Sin estudiantes }
  }
}

📄 usuarios/usuario-list/


2. Rutas: la URL decide qué componente se muestra

La aplicación tiene las rutas en dos versiones equivalentes:

export const routes: Routes = [
  { path: '', redirectTo: 'clases', pathMatch: 'full' },

  { path: 'clases', component: ClaseListComponent, title: 'Cursos' },
  { path: 'clases/nueva', component: ClaseFormComponent, title: 'Nuevo curso' },
  { path: 'clases/:id/editar', component: ClaseFormComponent, title: 'Editar curso' },

  { path: 'usuarios', component: UsuarioListComponent, title: 'Estudiantes' },
  // 'nuevo' va ANTES de ':id': si no, «nuevo» se tomaría como un id
  { path: 'usuarios/nuevo', component: UsuarioFormComponent, title: 'Nuevo estudiante' },
  { path: 'usuarios/:id', component: UsuarioDetailComponent, title: 'Estudiante' },
  { path: 'usuarios/:id/editar', component: UsuarioFormComponent, title: 'Editar estudiante' },

  { path: 'ejemplos/formulario', component: FormularioSimpleComponent },
  { path: 'ejemplos/componentes', component: ListaTarjetasComponent },

  // Siempre al final: coincide con cualquier URL
  { path: '**', component: NotFoundComponent, title: 'Página no encontrada' }
];

📄 app.routes.bk.ts — comentado ruta por ruta.

export const routes: Routes = [
  { path: '', redirectTo: 'clases', pathMatch: 'full' },
  { path: 'clases',
    loadChildren: () => import('./clases/clases.routes').then(m => m.CLASES_ROUTES) },
  { path: 'usuarios',
    loadChildren: () => import('./usuarios/usuarios.routes').then(m => m.USUARIOS_ROUTES) },
  { path: '**', component: NotFoundComponent }
];

📄 app.routes.ts · usuarios/usuarios.routes.ts

app.routes.ts (la que usa la app) app.routes.bk.ts
Dónde están las rutas Repartidas por área Un solo arreglo
Cómo se carga el componente loadComponent: () => import(...): al visitar la ruta component: ...: en el paquete inicial
Cuándo conviene Apps grandes, equipos por áreas Apps pequeñas, o para enseñar

Para usar la versión de un solo archivo, en app.config.ts se cambia import { routes } from './app.routes'; por './app.routes.bk'.

URL Componente Qué recibe
/ redirige a /clases —
/usuarios UsuarioListComponent —
/usuarios/nuevo UsuarioFormComponent id sin valor: crear
/usuarios/7 UsuarioDetailComponent id = "7"
/usuarios/7/editar UsuarioFormComponent id = "7": editar
/clases?usuarioId=7 ClaseListComponent usuarioId = "7"
/lo-que-sea NotFoundComponent ruta comodín **

Leer el parámetro y navegar

// app.config.ts: los parámetros de ruta llegan como input()
provideRouter(routes, withComponentInputBinding())

// usuario-detail · /usuarios/:id
readonly id = input.required<string>();

ngOnInit(): void {
  const id = Number(this.id());          // "7" → 7
  this.service.get(id).subscribe(/* ... */);
}
<a routerLink="/usuarios">Estudiantes</a>
<a [routerLink]="['/usuarios', u.id, 'editar']">Editar</a>
// Desde el código, por ejemplo después de guardar
this.router.navigate(['/usuarios', guardado.id]);

No usar href dentro de la app

<a href="/usuarios"> recarga toda la SPA y se pierde el estado. Siempre routerLink.


3. Formularios: ejemplo sencillo

Ruta: /ejemplos/formulario · 📄 ejemplos/formulario-simple/

Usa directamente las clases de Angular FormGroup y FormControl.

La clase

@Component({
  selector: 'app-formulario-simple',
  imports: [ReactiveFormsModule],
  templateUrl: './formulario-simple.component.html'
})
export class FormularioSimpleComponent {
  private service = inject(UsuarioService);

  form = new FormGroup({                                          // [1]
    nombre: new FormControl('', {                                 // [2]
      nonNullable: true, validators: Validators.required }),
    correo: new FormControl('', {
      nonNullable: true,
      validators: [Validators.required, Validators.email] })
  });

  mensaje = signal('');

  guardar(): void {
    if (this.form.invalid) {                                      // [3]
      this.form.markAllAsTouched();
      return;
    }
    this.service.create(this.form.getRawValue()).subscribe({     // [4]
      next: usuario => {
        this.mensaje.set(`Estudiante creado con id ${usuario.id}`);
        this.form.reset();
      },
      error: err => this.mensaje.set(err.error?.message ?? 'No fue posible guardar')
    });
  }
}
  1. FormGroup agrupa los campos en un solo formulario.
  2. FormControl es un campo: valor inicial '' y sus reglas. nonNullable: true hace que al limpiar vuelva a '' y no a null.
  3. Si alguna regla falla, se marcan todos los campos como tocados (aparecen los errores) y no se envía.
  4. getRawValue() da { nombre, correo }, que es lo que espera el backend. El servicio hace el POST.

El template

<form [formGroup]="form" (ngSubmit)="guardar()">          <!-- [1] -->
  <label>
    Nombre
    <input formControlName="nombre">                        <!-- [2] -->
  </label>
  @if (form.controls.nombre.touched && form.controls.nombre.invalid) {   <!-- [3] -->
    <small class="error">El nombre es obligatorio</small>
  }

  <label>
    Correo
    <input formControlName="correo">
  </label>
  @if (form.controls.correo.touched && form.controls.correo.invalid) {
    <small class="error">Escribe un correo válido</small>
  }

  <button type="submit">Guardar</button>                   <!-- [4] -->
</form>

<p>{{ mensaje() }}</p>                                     <!-- [5] -->
  1. [formGroup] une el <form> con la clase; (ngSubmit) llama a guardar() sin recargar la página.
  2. formControlName une el input con su FormControl.
  3. El error solo se ve si el campo fue tocado y es inválido.
  4. type="submit" es lo que dispara (ngSubmit).
  5. mensaje() es un signal: muestra el resultado al guardar.

getRawValue() y el viaje al servicio

# Paso Ejemplo
1 Formulario: lo que escribió el usuario nombre: Ana Pérez · correo: ana@javeriana.edu.co
2 getRawValue(): junta los controles en un objeto { nombre: 'Ana Pérez', correo: 'ana@javeriana.edu.co' }
3 service.create(u): el servicio hace la petición http.post<Usuario>('/api/estudiante/usuarios', u)
4 Spring recibe el JSON en el DTO crear(@Valid @RequestBody UsuarioRequest request)
form.value form.getRawValue()
Omite los campos deshabilitados y TypeScript los ve como opcionales (nombre?: string) Trae todos los campos con su tipo exacto: { nombre: string; correo: string } = UsuarioRequest

Los nombres de los controles (nombre, correo) son iguales a los del DTO UsuarioRequest; por eso el objeto se envía tal cual. Si un control se llamara email, Spring no lo reconocería y respondería 400.

Las mismas reglas en los dos lados

Campo Angular (cliente) Spring (UsuarioRequest)
nombre Validators.required @NotBlank
correo Validators.required · Validators.email @NotBlank · @Email
correo único No se puede validar en el cliente 409 Conflict desde el servicio

La versión completa

El formulario real de estudiantes (usuarios/usuario-form/) hace lo mismo con FormBuilder, sirve para crear y editar, y muestra los errores por campo que devuelve el backend. Está comentado línea por línea.


4. Componentes dentro de componentes

Ruta: /ejemplos/componentes · 📄 ejemplos/tarjeta-usuario/ · ejemplos/lista-tarjetas/

Un componente padre (ListaTarjetasComponent) carga los estudiantes y dibuja una tarjeta hija (TarjetaUsuarioComponent) por cada uno.

flowchart TD
    P["ListaTarjetasComponent (padre)"] -- "[usuario] baja" --> T1[app-tarjeta-usuario]
    P -- "[usuario] baja" --> T2[app-tarjeta-usuario]
    P -- "[usuario] baja" --> T3[app-tarjeta-usuario]
    T1 -. "(seleccionar) sube" .-> P

Los datos bajan con input(); los eventos suben con output().

Tres pasos

Paso Dónde Código
1. El hijo declara qué recibe tarjeta-usuario.component.ts usuario = input.required<Usuario>();
2. El padre lo importa lista-tarjetas.component.ts imports: [TarjetaUsuarioComponent]
3. El padre usa el selector y le pasa el dato template del padre <app-tarjeta-usuario [usuario]="u" />

El hijo: tarjeta-usuario

@Component({
  selector: 'app-tarjeta-usuario',
  templateUrl: './tarjeta-usuario.component.html'
})
export class TarjetaUsuarioComponent {
  usuario = input.required<Usuario>();   // dato que BAJA del padre
  seleccionar = output<Usuario>();       // evento que SUBE al padre
}
<article class="tarjeta">
  <h3>{{ usuario().nombre }}</h3>
  <p>{{ usuario().correo }}</p>
  <button type="button" (click)="seleccionar.emit(usuario())">Seleccionar</button>
</article>
  • usuario() lee el dato que mandó el padre.
  • seleccionar.emit(…) avisa al padre con ese usuario.

El padre: lista-tarjetas

@Component({
  selector: 'app-lista-tarjetas',
  imports: [TarjetaUsuarioComponent],
  templateUrl: './lista-tarjetas.component.html'
})
export class ListaTarjetasComponent implements OnInit {
  private service = inject(UsuarioService);

  usuarios = signal<Usuario[]>([]);
  elegido = signal<Usuario | null>(null);

  ngOnInit(): void {
    this.service.list().subscribe(data => this.usuarios.set(data));
  }
}
<h2>Estudiantes · ejemplo de componentes</h2>

@if (elegido(); as u) {
  <p>Seleccionaste a <b>{{ u.nombre }}</b></p>
}

<div class="tarjetas">
  @for (u of usuarios(); track u.id) {
    <app-tarjeta-usuario [usuario]="u" (seleccionar)="elegido.set($event)" />
  }
</div>
  • imports: [TarjetaUsuarioComponent] permite usar la etiqueta <app-tarjeta-usuario>.
  • [usuario]="u" entrega a cada tarjeta el estudiante de esa vuelta del @for.
  • (seleccionar) escucha el output() del hijo; $event es el usuario que el hijo pasó en emit(...).

Ejercicio

Extraer la tabla de cursos de clase-list.component.html a un componente app-clase-tabla con clases = input.required<Clase[]>() y eliminar = output<Clase>(). El padre sigue decidiendo qué hacer al eliminar: confirmar y llamar a ClaseService.delete.


5. Cierre: lo que deben poder explicar

Tema Idea clave
Servicio y Observable La petición sale al hacer subscribe; el resultado se guarda en un signal
Modelo Una interface espejo del DTO; Omit<T, 'id'> para crear
Rutas URL → componente; parámetros como input(); routerLink y Router
Formularios FormGroup, FormControl, validadores, touched/invalid y getRawValue()
Composición Importar, usar el selector y pasar datos con [input]; los eventos suben con output()

Para la casa

En la ficha del estudiante (/usuarios/:id), permitir eliminar cursos reutilizando el componente app-clase-tabla del ejercicio y ClaseService.delete.