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¶
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');
}
});
}
status = 'loading': el template muestra «Cargando…».list()crea el Observable: todavía no sale nada a la red.subscribe()dispara la petición: apareceGET /usuariosen Network.- Spring responde 200 con JSON.
next(data): llega unUsuario[]ya tipado y se guarda en el signal.status = 'success': Angular pinta la tabla.- Si falla (backend apagado, 500…), se ejecuta
errory 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 }
}
}
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 (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')
});
}
}
FormGroupagrupa los campos en un solo formulario.FormControles un campo: valor inicial''y sus reglas.nonNullable: truehace que al limpiar vuelva a''y no anull.- Si alguna regla falla, se marcan todos los campos como tocados (aparecen los errores) y no se envía.
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] -->
[formGroup]une el<form>con la clase;(ngSubmit)llama aguardar()sin recargar la página.formControlNameune el input con suFormControl.- El error solo se ve si el campo fue tocado y es inválido.
type="submit"es lo que dispara(ngSubmit).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¶
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));
}
}
imports: [TarjetaUsuarioComponent]permite usar la etiqueta<app-tarjeta-usuario>.[usuario]="u"entrega a cada tarjeta el estudiante de esa vuelta del@for.(seleccionar)escucha eloutput()del hijo;$eventes el usuario que el hijo pasó enemit(...).
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.