Parte 2 — ArticleFormComponent cu Reactive Forms
Un singur component pentru create + edit. Distinge cazurile printr-un flag setat din route param. Toate apelurile la API trec prin ArticleService din Ex 1.
Reactive Forms vs Template-Driven
Angular oferă două API-uri pentru formulare. Le folosim pe Reactive — mai verbose în component, mult mai testabile, validare custom mai ușoară.
| Aspect | Template-Driven (FormsModule) |
Reactive (ReactiveFormsModule) |
|---|---|---|
| Source of truth | DOM (template-ul) | TypeScript (componenta) |
| Sintaxă bind | [(ngModel)]="title" |
formControlName="title" |
| Validare | atribute HTML (required, minlength) |
Validators în FormBuilder |
| Asincron / dinamic | greu | natural (addControl, valueChanges) |
| Testare | trebuie să randezi DOM-ul | testezi form.value direct |
| Mai folosit pentru | formulare scurte (login, search) | formulare complexe, CRUD |
Pentru Lab 12 — formular cu validare per câmp + condiții (edit vs create) — Reactive e alegerea evidentă. RegisterComponent și LoginComponent din Lab 11 deja folosesc Reactive — pattern-ul e familiar.
ReactiveFormsModule e deja importat în AppModule.
FormBuilder, FormGroup, Validators
Trei piese:
FormBuilder— service injectabil care construiește grupurile / controalele cu sintaxă concisă (fb.group({...})în loc denew FormGroup({ title: new FormControl(...) })).FormGroup— colecție de controale, cuvalid,value,errors,touched, etc. agregate.Validators— validatori built-in:required,minLength(n),maxLength(n),email,pattern(regex). PlusValidators.compose([...])pentru combinare.
Schelet de inițializare:
this.form = this.fb.group({
title: ['', [Validators.required, Validators.minLength(5)]],
content: ['', [Validators.required, Validators.minLength(20)]],
categoryId: [null, Validators.required],
tagIds: [[] as number[]] // pre-built scaffold: multi-select pe tag-uri
});
Sintaxa ['', [Validators.required, ...]] = [valoareInitiala, validatori]. Fără [] în jurul validatorilor poți pune unul singur direct: [null, Validators.required].
Scaffold pre-built —
tagIds: template-ul HTML dinLab12_startare deja un bloc de checkbox-uri pentru tag-uri legat deformControlName="tagIds". Voi doar adăugați linia de mai sus înfb.group(...); restul (loadallTags, render checkbox-uri, toggle handler) e deja înLab12_start. Vedeți00-intro.md§ “Scaffolding adițional” pentru context.
Notă: Validatorii trebuie să corespundă cu cei de pe DTO-ul backend. Backend-ul cere
MinLength(5)la title șiMinLength(20)la content — frontend-ul ar trebui să le impună la fel, ca să prinzi erorile înainte de request, nu după400de la API. Sursa de adevăr e backend-ul; frontend-ul e o copie pentru UX.
Skeletul componentei (deja în Lab12_start)
src/app/features/articles/article-form/article-form.component.ts:
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ArticleService } from '../../../core/services/article';
import { CategoryService } from '../../../core/services/category';
import { Category } from '../../../shared/models/article';
@Component({
selector: 'app-article-form',
templateUrl: './article-form.component.html',
styleUrl: './article-form.component.css'
})
export class ArticleFormComponent implements OnInit {
form!: FormGroup;
categories: Category[] = [];
isEditMode = false;
articleId: number | null = null;
loading = false;
error: string | null = null;
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private articleService: ArticleService,
private categoryService: CategoryService
) {}
ngOnInit(): void {
// TODO Lab 12 (Ex 2 si Ex 3)
}
onSubmit(): void {
// TODO Lab 12 (Ex 2 si Ex 3)
}
cancel(): void {
this.router.navigate(['/articles']);
}
}
Skeletonul are 5 dependențe injectate: FormBuilder (construire form), ActivatedRoute (citire param :id), Router (navigate la final), ArticleService (load + save), CategoryService (dropdown).
form! are ! pentru că o construim în ngOnInit, nu la declare — TypeScript altfel se plânge că form nu-i inițializat. ! îi spune „am grijă, va fi setat la prima accesare".
Mod create — ngOnInit + onSubmit
În ngOnInit (mod create):
ngOnInit(): void {
this.form = this.fb.group({
title: ['', [Validators.required, Validators.minLength(5)]],
content: ['', [Validators.required, Validators.minLength(20)]],
categoryId: [null, Validators.required]
});
this.categoryService.getAll().subscribe({
next: cats => this.categories = cats,
error: () => this.error = 'Nu s-au putut incarca categoriile'
});
}
Două chestii: construirea form-ului (sincron) și încărcarea categoriilor (async, subscribe).
În onSubmit:
onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched(); // forteaza afisarea erorilor
return;
}
this.loading = true;
this.error = null;
const dto = this.form.value; // { title, content, categoryId, tagIds }
this.articleService.create(dto).subscribe({
next: (article) => this.afterSave(article.id), // pre-built: upload imagine (daca selectata) + navigate
error: err => {
this.error = 'Eroare la salvare';
this.loading = false;
}
});
}
Scaffold pre-built —
afterSave(articleId): template-ul are un<input type="file">care seteazăselectedFile. MetodaafterSave(pre-built înLab12_start) face automatarticleService.uploadImage(...)dacă există fișier selectat, apoi navighează la/articles/:id. Voi apelați doarthis.afterSave(article.id)în callback-ulnext— nu vă atingeți de logica de upload.
Notați:
- Guard cu
form.invalid— chiar dacă[disabled]="form.invalid"pe buton, dublu-check (cineva poate șterge atributul din DevTools). markAllAsTouched()— fără asta, validatorii s-au evaluat dar mesajele de eroare nu se afișează (pentru că template-ul folosește*ngIf="control.touched"). Click pe buton fără să fi atins câmpurile = nimic nu apare. CumarkAllAsTouched(), toate câmpurile devintouchedinstant.this.form.valueare exact formaCreateArticleDtopentru că am numit cheile la fel. Convenția salvează unas CreateArticleDto.this.loading— disable pe buton + indicator vizual. Laerror, resetăm; lanextnu mai contează (navigăm).
Template-ul (HTML)
src/app/features/articles/article-form/article-form.component.html:
<div class="container mt-5">
<h1>{{ isEditMode ? 'Editare articol' : 'Articol nou' }}</h1>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Title -->
<div class="mb-3">
<label for="title" class="form-label">Titlu</label>
<input id="title" class="form-control" formControlName="title">
<div *ngIf="form.get('title')?.touched && form.get('title')?.errors as e"
class="text-danger small">
<span *ngIf="e['required']">Titlul este obligatoriu.</span>
<span *ngIf="e['minlength']">Minim {{ e['minlength'].requiredLength }} caractere.</span>
</div>
</div>
<!-- Content -->
<div class="mb-3">
<label for="content" class="form-label">Conținut</label>
<textarea id="content" rows="6" class="form-control" formControlName="content"></textarea>
<div *ngIf="form.get('content')?.touched && form.get('content')?.errors as e"
class="text-danger small">
<span *ngIf="e['required']">Conținutul este obligatoriu.</span>
<span *ngIf="e['minlength']">Minim {{ e['minlength'].requiredLength }} caractere.</span>
</div>
</div>
<!-- Category -->
<div class="mb-3">
<label for="categoryId" class="form-label">Categorie</label>
<select id="categoryId" class="form-select" formControlName="categoryId">
<option [ngValue]="null" disabled>-- alege --</option>
<option *ngFor="let c of categories" [ngValue]="c.id">{{ c.name }}</option>
</select>
<div *ngIf="form.get('categoryId')?.touched && form.get('categoryId')?.errors?.['required']"
class="text-danger small">
Selectați o categorie.
</div>
</div>
<!-- Error global -->
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<!-- Submit / Cancel -->
<button type="submit" class="btn btn-primary" [disabled]="form.invalid || loading">
{{ loading ? 'Se salveaza...' : (isEditMode ? 'Salveaza modificarile' : 'Publica articolul') }}
</button>
<button type="button" class="btn btn-secondary ms-2" (click)="cancel()">Anuleaza</button>
</form>
</div>
Ce face fiecare bucată
[formGroup]="form"pe<form>— leagă form-ul reactiv de DOM.(ngSubmit)="onSubmit()"— apelat laEnterîn input sau click pe<button type="submit">.formControlName="title"— leagă input-ul de control-ul cu acel nume dinform.form.get('title')?.errors— obiect cu cheile validatorilor care au eșuat ({required: true}sau{minlength: {actualLength, requiredLength}}).*ngIf="touched"— afișează erorile doar după ce user-ul a interacționat cu câmpul (sau dupămarkAllAsTouched()).[ngValue]vs[value]—[ngValue]permite valori de orice tip (numbers, objects);[value]doar string. PentrucategoryId: number, folosiți[ngValue].[disabled]="form.invalid || loading"pe buton — blochează submit-ul la form invalid sau în timpul cererii.
Notă:
form.get('title')?.errorscu?.pentru că TypeScript nu garantează căget('title')returnează non-null — dacă scrieți greșit numele, returneazănull. Operatorul?.evită crash-ul în template.
Mod edit — diferențele
În ngOnInit, după ce construiți form-ul, verificați route param-ul :id:
ngOnInit(): void {
this.form = this.fb.group({...});
this.categoryService.getAll().subscribe({
next: cats => this.categories = cats,
error: () => this.error = 'Nu s-au putut incarca categoriile'
});
const idParam = this.route.snapshot.paramMap.get('id');
if (idParam) {
this.isEditMode = true;
this.articleId = Number(idParam);
this.articleService.getById(this.articleId).subscribe({
next: article => {
this.form.patchValue({
title: article.title,
content: article.content,
categoryId: article.categoryId,
tagIds: article.tags.map(t => t.id) // pre-built scaffold: populeaza tag selector
});
this.existingImagePath.set(article.imagePath ?? null); // pre-built scaffold: preview imagine
},
error: () => this.error = 'Articolul nu a fost gasit'
});
}
}
route.snapshot.paramMap.get('id') — citește :id din URL (/articles/42/edit → '42'). Returnează string | null. Dacă există → mod edit.
form.patchValue(...) — populează doar câmpurile menționate. Alternativă: form.setValue(...) cere toate câmpurile (eroare dacă lipsește unul). patchValue e relaxat — preferat la load din API care poate returna mai multe câmpuri decât avem în form.
În onSubmit, ramificați după isEditMode:
onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.loading = true;
this.error = null;
const dto = this.form.value;
const error = () => {
this.error = 'Eroare la salvare';
this.loading = false;
};
if (this.isEditMode && this.articleId !== null) {
this.articleService.update(this.articleId, dto).subscribe({
next: () => this.afterSave(this.articleId!), // pre-built: upload imagine + navigate
error
});
} else {
this.articleService.create(dto).subscribe({
next: (article) => this.afterSave(article.id), // pre-built: upload imagine + navigate
error
});
}
}
Aceeași validare, aceeași gestionare de erori — diferă doar service call-ul (update(id, dto) vs create(dto)).
De ce
if/elseși nu un ternarconst obs = ... ? update(...) : create(...)? Pentru căupdate()returneazăObservable<void>șicreate()returneazăObservable<Article>— TypeScript ar tipiza variabila caObservable<void> | Observable<Article>, iarsubscribe()nu poate alege între overload-urile celor două (eroare TS2349 „This expression is not callable"). Soluția: subscribe pe fiecare branch separat, cu handlers extrași într-o constantă ca să nu duplici codul.
Notă: Două subscribe-uri concomitente (
getAllla categorii +getByIdla articol în mod edit) e OK pentru lab. În producție le-ai compune cuforkJoin([...])ca să afișezi loader-ul până se rezolvă ambele. Sau ai folosiasyncpipe în template care gestionează automat.
De ce if (this.form.invalid) return; și nu [disabled] doar?
[disabled]="form.invalid" previne submit-ul prin click pe buton, dar:
- DevTools poate șterge atributul
disabled Enterîn input poate trigger-ui submit-ul în unele browsere chiar dacă butonul e disabled- Atacul scriptat (Postman, fetch din consolă) ignoră complet template-ul
Backend-ul tot validează (DataAnnotations pe DTO + ModelState.IsValid), deci suntem safe — dar a verifica și pe frontend evită un round-trip inutil.
Exercițiul 2 (1p) — ArticleFormComponent mod create
Cerințe
- Implementați
ngOnInitînArticleFormComponent:- Construiți
this.formcuFormBuilder: 4 controale (titlecurequired + minLength(5),contentcurequired + minLength(20),categoryIdcurequired,tagIds: [[]]— scaffold pre-built pentru multi-select). - Apelați
categoryService.getAll().subscribe(...)și salvați rezultatul înthis.categories.
- Construiți
- Implementați
onSubmit:- Dacă
form.invalid, apelațiform.markAllAsTouched()șireturn. - Construiți
dto = this.form.valueși apelațiarticleService.create(dto).subscribe(...). - La succes, apelați
this.afterSave(article.id)(scaffold pre-built: face upload imagine dacă selectată + navigate). La eroare setațithis.error.
- Dacă
- Template-ul
article-form.htmle complet pre-construit înLab12_start— include[formGroup]="form", cele 4 grupuri de câmp (title, content, categoryId, tag selector), file input pentru imagine, butoane submit + cancel cu validation display. Voi nu modificați template-ul. - Test end-to-end:
- Login cu
admin@newsportal.com / Admin@123. - Click pe „+ Articol nou" din lista de articole → URL devine
/articles/new. - Câmpuri goale → buton Submit disabled, fără mesaje de eroare (până faceți touch).
- Touch pe câmp + leave → mesaje de eroare apar.
- Completați câmpurile valid + selectați categoria → submit activ → click → articol creat → redirect la lista cu noul articol vizibil.
- Console verify: în DevTools tab Network, cererea
POST /api/articlestrebuie să apară cu status201și bodyCreateArticleDto.
- Login cu
Output așteptat
/articles/newarată formular cu 3 câmpuri populate cu valori goale.- Validările apar doar după touch, nu instant.
- Submit activ doar când form-ul e valid.
- La succes, articolul apare în listă; la eroare, mesaj global.
Exercițiul 3 (1p) — mod edit (route param + patchValue + update)
Cerințe
- Extindeți
ngOnInitcu logica de edit:- Citiți
route.snapshot.paramMap.get('id'). - Dacă există: setați
isEditMode = true,articleId = Number(idParam), apelațiarticleService.getById(articleId)șipatchValuepe form cu câmpurile articolului — inclusivtagIds: article.tags.map(t => t.id)(mapare scaffold pentru tag selector) șithis.existingImagePath.set(article.imagePath ?? null)(scaffold pentru preview imagine).
- Citiți
- Refactor
onSubmit:- În loc să apelați doar
create(dto), ramificați: dacăisEditMode && articleId !== null→update(articleId, dto), altfelcreate(dto). - Restul (subscribe, navigate, error handling) rămâne identic.
- În loc să apelați doar
- Test end-to-end:
- Cu un articol creat la Ex 2 (sau seedat), accesați direct
/articles/<id>/editîn URL. - Câmpurile trebuie populate cu valorile articolului.
- Modificați conținutul → submit → articolul actualizat în listă.
- Buton „Editează" din
ArticleListComponent(Ex 4 va face*ngIfpe el) — dacă-l forțați vizibil acum cucanModifyreturnândtruetemporar, click-ul navighează la/articles/<id>/edit. - Console verify:
PUT /api/articles/<id>cu status204în Network.
- Cu un articol creat la Ex 2 (sau seedat), accesați direct
- Edge case: dacă editați ca un user obișnuit un articol care nu vă aparține, backend-ul întoarce
403 Forbidden. Verificați căthis.errorse setează însubscribe.error(mesajul HTTP nu trebuie să iasă raw în UI).
Output așteptat
/articles/<id>/editdeschide formularul pre-populat cu articolul.- Header-ul devine „Editare articol", butonul „Salvează modificările".
- Submit triggerează
PUT, succes → redirect la listă cu modificarea vizibilă. - Pe
403(user nu-i owner), mesaj global de eroare în UI, nu crash.