12

Lab 12 - Angular Frontend (Part 2) - CRUD, Reactive Forms, Role-based UI

Partea 2 din 5

Actualizat 2026-05-17Sursă pe GitHub ↗Cod start

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 de new FormGroup({ title: new FormControl(...) })).
  • FormGroup — colecție de controale, cu valid, value, errors, touched, etc. agregate.
  • Validators — validatori built-in: required, minLength(n), maxLength(n), email, pattern(regex). Plus Validators.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 din Lab12_start are deja un bloc de checkbox-uri pentru tag-uri legat de formControlName="tagIds". Voi doar adăugați linia de mai sus în fb.group(...); restul (load allTags, render checkbox-uri, toggle handler) e deja în Lab12_start. Vedeți 00-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 și MinLength(20) la content — frontend-ul ar trebui să le impună la fel, ca să prinzi erorile înainte de request, nu după 400 de 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. Metoda afterSave (pre-built în Lab12_start) face automat articleService.uploadImage(...) dacă există fișier selectat, apoi navighează la /articles/:id. Voi apelați doar this.afterSave(article.id) în callback-ul next — 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. Cu markAllAsTouched(), toate câmpurile devin touched instant.
  • this.form.value are exact forma CreateArticleDto pentru că am numit cheile la fel. Convenția salvează un as CreateArticleDto.
  • this.loading — disable pe buton + indicator vizual. La error, resetăm; la next nu 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 la Enter în input sau click pe <button type="submit">.
  • formControlName="title" — leagă input-ul de control-ul cu acel nume din form.
  • 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. Pentru categoryId: number, folosiți [ngValue].
  • [disabled]="form.invalid || loading" pe buton — blochează submit-ul la form invalid sau în timpul cererii.

Notă: form.get('title')?.errors cu ?. 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 ternar const obs = ... ? update(...) : create(...)? Pentru că update() returnează Observable<void> și create() returnează Observable<Article> — TypeScript ar tipiza variabila ca Observable<void> | Observable<Article>, iar subscribe() 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 (getAll la categorii + getById la articol în mod edit) e OK pentru lab. În producție le-ai compune cu forkJoin([...]) ca să afișezi loader-ul până se rezolvă ambele. Sau ai folosi async pipe î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

  1. Implementați ngOnInit în ArticleFormComponent:
    • Construiți this.form cu FormBuilder: 4 controale (title cu required + minLength(5), content cu required + minLength(20), categoryId cu required, tagIds: [[]] — scaffold pre-built pentru multi-select).
    • Apelați categoryService.getAll().subscribe(...) și salvați rezultatul în this.categories.
  2. Implementați onSubmit:
    • Dacă form.invalid, apelați form.markAllAsTouched() și return.
    • Construiți dto = this.form.value și apelați articleService.create(dto).subscribe(...).
    • La succes, apelați this.afterSave(article.id) (scaffold pre-built: face upload imagine dacă selectată + navigate). La eroare setați this.error.
  3. Template-ul article-form.html e complet pre-construit în Lab12_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.
  4. 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/articles trebuie să apară cu status 201 și body CreateArticleDto.

Output așteptat

  • /articles/new arată 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

  1. Extindeți ngOnInit cu logica de edit:
    • Citiți route.snapshot.paramMap.get('id').
    • Dacă există: setați isEditMode = true, articleId = Number(idParam), apelați articleService.getById(articleId) și patchValue pe form cu câmpurile articolului — inclusiv tagIds: article.tags.map(t => t.id) (mapare scaffold pentru tag selector) și this.existingImagePath.set(article.imagePath ?? null) (scaffold pentru preview imagine).
  2. Refactor onSubmit:
    • În loc să apelați doar create(dto), ramificați: dacă isEditMode && articleId !== nullupdate(articleId, dto), altfel create(dto).
    • Restul (subscribe, navigate, error handling) rămâne identic.
  3. 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 *ngIf pe el) — dacă-l forțați vizibil acum cu canModify returnând true temporar, click-ul navighează la /articles/<id>/edit.
    • Console verify: PUT /api/articles/<id> cu status 204 în Network.
  4. 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.error se setează în subscribe.error (mesajul HTTP nu trebuie să iasă raw în UI).

Output așteptat

  • /articles/<id>/edit deschide 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.