12

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

Partea 1 din 5

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

CTI — Dezvoltarea Aplicațiilor Web — Laborator 12

Angular Frontend (Part 2) — CRUD complet, Reactive Forms, Role-based UI


Obiective

Lab 11 a adus partea de citire și autentificare în Angular: liste, detalii, login, register. Lab 12 închide cercul cu scriere: create / edit / delete articole, plus UI condiționat de rol (un user obișnuit nu vede butonul „Șterge" pe articole care nu-i aparțin).

Backend-ul rămâne în mare neschimbat — toate endpoint-urile de scriere existau deja din Lab 8 (POST/PUT/DELETE /api/articles cu JWT + ownership check). Adăugăm doar un endpoint nou: GET /api/categories (dropdown-ul de categorii din formular trebuie populat de undeva).

Ce facem:

  • ArticleService extinscreate(), update(), delete() cu HttpClient.post/put/delete și RxJS Observable<void> pentru operațiile fără body de răspuns
  • CategoryService nou — un singur getAll(), alimentează dropdown-ul din formular
  • ArticleFormComponent — un singur component pentru create + edit (route param :id distinge cazurile), cu reactive forms: FormBuilder, Validators, mesaje de validare per câmp
  • Routing nou/articles/new și /articles/:id/edit, ambele protejate cu AuthGuard
  • Role-based UIcanModify(article) în ArticleListComponent care întoarce true doar pentru autor sau Admin; butoanele „Editează" și „Șterge" sunt randate condiționat cu *ngIf
  • Delete cu confirm + refreshconfirm() browser → articleService.delete().subscribe(...) → reîncărcare listă

Lab 11 a fost citire. Lab 12 e scriere + autorizare la nivel de UI.

Ce-i deja în Lab12_start

Skeletonul vine cu mai mult decât doar Lab 11 — ca să nu pierdem timp pe boilerplate, am pre-scris:

Backend (refactor minor de consistență):

  • Mappings/ArticleViewModelMappings.cs — extension method article.ToViewModel() care înlocuiește mapping-ul inline din 5 acțiuni MVC. Aceeași idee ca ToDto() din Lab 8.
  • Authorization/ClaimsPrincipalExtensions.csUser.CanModifyArticle(article) extension, înlocuiește IsOwnerOrAdmin privat care era duplicat în 2 controllere.
  • Services/IJwtService.cs + JwtService.cs — generarea de JWT mutată din AuthApiController (28 de linii erau în controller). Service-ul e înregistrat în Program.cs cu AddScoped.
  • Controllers/Api/CategoriesApiController.cs (NOU) — GET /api/categories returnează List<CategoryDto>. Pre-scris pentru că dropdown-ul din formular ar fi blocat altfel întreg laboratorul.

Frontend (schelet pentru exerciții):

  • core/services/article.tsgetAll() și getById() din Lab 11 funcționale; create() / update() / delete() aruncă Error('TODO Lab 12') — completați la Ex 1.
  • core/services/category.ts (NOU) — getAll() aruncă TODO; completați la Ex 1.
  • features/articles/article-form/ (NOU) — componenta cu cancel() funcțional, restul e TODO. Declarată în AppModule, înregistrată în router pe /articles/new și /articles/:id/edit.
  • features/articles/article-list/ — adăugate butonul „+ Articol nou" (vizibil dacă autentificat) și *ngIf="canModify(article)" pe Edit/Delete. canModify() returnează hardcodat false — completați la Ex 4.
  • shared/models/article.ts — adăugate interfețele CreateArticleDto, UpdateArticleDto.
  • app-routing.module.ts — rutele noi cu AuthGuard. Ordinea contează: articles/new înainte de articles/:id, altfel :id ar prinde și 'new'.

Scaffolding adițional (Tags + Imagini) — pre-construit, nu se atinge la laborator

Lab 12 acoperă explicit doar CRUD-ul de bază + role-based UI prin 4 exerciții. Pentru ca aplicația rezultată (Lab12_final) să fie funcțional 1-1 cu varianta Razor din Lab 10 — și să intre direct în lab-ul de Docker fără rescrieri — am pre-construit în Lab12_start două zone extra: tag-urile M2M și upload-ul de imagini. Studenții nu modifică nimic în zonele astea; sunt marcate explicit cu comentariul SCAFFOLD pre-built Lab12 în cod.

Tags M2M (/api/tags + selector în formular):

  • Backend: DTOs/TagDto.cs, Controllers/Api/TagsApiController.cs (GET /api/tags), ArticleDto returnează List<TagDto> Tags, CreateArticleDto și UpdateArticleDto acceptă List<int>? TagIds. ArticlesApiController.Create și Update aplică tag-urile via ITagService.GetByIdsAsync (PUT cu semantica „lista trimisă = lista finală").
  • Frontend: core/services/tag.ts (NOU, getAll() funcțional), shared/models/article.ts are interfața Tag { id, name } și Article.tags: Tag[]. Form-ul HTML include un grup de checkbox-uri multi-select (onTagToggle(tagId, checked) în TS pre-built). Lista de articole și pagina de detaliu afișează badge-uri cu tag-uri.
  • Punct de contact cu exercițiile: studentul adaugă un singur câmp în form-group la Ex 2/3 — tagIds: [[] as number[]]. Template-ul deja consumă acest control prin scaffolding.

Imagini (POST /api/articles/{id}/image + file input):

  • Backend: endpoint dedicat separat de Create/Update (rămân JSON pur). POST /api/articles/{id}/image acceptă multipart/form-data cu cheia file, validează extensia și dimensiunea (max 5 MB, png/jpg/jpeg/gif/webp), scrie în wwwroot/images/article-{id}-{guid}.{ext} și setează Article.ImagePath. Static files servite via app.UseStaticFiles() (deja activ).
  • Frontend: ArticleService.uploadImage(id, file) pre-built. Form-ul are <input type="file"> care setează selectedFile în component. afterSave(articleId) (metoda pre-built) face upload-ul dacă există fișier, apoi navighează la detaliu — încapsulează multipart-ul în afara exercițiilor.
  • Punct de contact cu exercițiile: la Ex 2/3, în callback-ul de succes al create/update, studentul apelează this.afterSave(article.id) în loc de router.navigate(...). Restul logicii de upload e ascuns în scaffold.
  • Detail + list afișează imaginea cu <img [src]="backendUrl + a.imagePath"> unde backendUrl = environment.apiUrl.

Seed: Data/SeedData.cs seed-uiește deja 5 tag-uri (C#, Web, Tutorial, .NET, Database) + 4 articole cu ImagePath setat (fișierele lipă în backend/wwwroot/images/). Așa lista inițială are atât tag-uri vizibile pe primul articol, cât și imagini reale.

De ce e relevant didactic, chiar dacă nu e exercițiu: la Lab 13 (deployment Docker) plecăm de la Lab12_final și împachetăm ce avem — exact ce s-ar întâmpla în orice proiect real. Scaffolding-ul ăsta înseamnă că aplicația livrată e completă, nu un demo redus care „ar trebui să funcționeze cu mai mult timp".

Ce rămâne pe voi: să umpleți // TODO Lab 12 din 5 locuri (3 servicii + form + canModify/delete). Detalii pe exerciții.

Punctaj (4p)

  • 4 exerciții × 1p fiecare.
Exercițiu Subiect Punctaj
Ex 1 ArticleService.create/update/delete + CategoryService.getAll 1p
Ex 2 ArticleFormComponent mod create (form + submit + navigate) 1p
Ex 3 ArticleFormComponent mod edit (route param + patchValue + update) 1p
Ex 4 canModify(article) + deleteArticle cu confirm + refresh 1p

Recapitulare Lab 11

Din laboratoarele anterioare avem:

  • Lab 6: Repository + Service Layer + MVC + async/await
  • Lab 7: ASP.NET Core Identity, Roles, content ownership pe AuthorId
  • Lab 8: Web API + DTOs + Swagger + JWT Bearer
  • Lab 9: unit tests (xUnit, EF InMemory) + integration tests (WebApplicationFactory<Program>)
  • Lab 10: Many-to-Many (Tags), middleware custom, Serilog
  • Lab 11: workspace Angular separat + CORS, modele TS, AuthService cu JWT decode în localStorage, ArticleService read-only, HttpConfigInterceptor care atașează Authorization: Bearer, AuthGuard, routing, componente LoginComponent/RegisterComponent/ArticleListComponent/ArticleDetailComponent/HeaderComponent

Lab12_start continuă fără să strice nimic. Tot ce mergea în Lab 11 merge în continuare:

  • dotnet run în Lab12_start/backend/ → backend (porturi din launchSettings.json)
  • ng serve în Lab12_start/news-portal-app/ → frontend pe 4200
  • Login cu admin@newsportal.com / Admin@123 (seedat din Lab 7) — primește rolul Admin în JWT

De ce un singur component pentru create + edit?

Tentația naturală e ArticleCreateComponent + ArticleEditComponent. Le scrii separat, fiecare cu form-ul lui — dar 90% din cod e identic: aceleași câmpuri, aceeași validare, același dropdown de categorii, aceeași logică de submit. Singurele diferențe:

Diferență Create Edit
Header „Articol nou" „Editare articol"
Pre-populare câmpuri '', 0 (default) patchValue(articol) din API
Submit method articleService.create(dto) articleService.update(id, dto)
Navigate după succes /articles/:newId (sau /articles) /articles/:id

Patru mici diferențe nu justifică două componente. Soluția: un singur component, distinge cazurile printr-un flag isEditMode setat din route param (/articles/new vs /articles/:id/edit). Toată logica formularului (build, validare, submit) rămâne în comun.

E același principiu cu Create.cshtml + Edit.cshtml din MVC din Lab 5 / Lab 6 — și acolo le-am scris separate, dar diferența era doar în @model și <form action>. În Angular, un component cu if/else e mai DRY.

De ce role-based UI dacă backend-ul oricum verifică?

Backend-ul trebuie să verifice — User.CanModifyArticle(article) în ArticlesApiController returnează 403 Forbidden dacă cineva încearcă să șteargă un articol care nu-i aparține. Asta e securitatea reală.

Atunci de ce și pe frontend? UX.

Aspect Backend check (singur) + UI check
Securitate OK — backend respinge cererea OK (la fel)
Experiență user vede butoanele, dă click, primește eroare nu vede butonul deloc
Confuzie „de ce-mi zice 403, nu sunt logat?" nu apare
Cereri inutile la server 1 request → 403 → re-randare cu eroare 0

Regula: frontend ascunde acțiuni, backend le respinge. Nu invers — niciodată „doar UI" fără backend, pentru că oricine poate apela API-ul direct (Postman, curl, devtools).

Pipeline-ul cererii la create / edit / delete

[ng serve :4200]                    [dotnet run :7001]
       |                                    |
       | 1. Click „+ Articol nou"           |
       |  -> router navigate /articles/new  |
       |  -> AuthGuard verifica token       |
       |  -> ArticleFormComponent          |
       |                                    |
       | 2. User completeaza form           |
       |  -> form.valid = true              |
       |                                    |
       | 3. Click „Salveaza"                |
       |  -> articleService.create(dto)     |
       |  -> HttpClient.post                |
       |  -> Interceptor adauga Bearer      |
       | --------- HTTP POST -------------->|
       |                                    | 4. ArticlesApiController.Create
       |                                    |    [Authorize JWT] -> OK
       |                                    |    AuthorId = User.NameIdentifier
       |                                    |    _articleService.AddAsync(...)
       |                                    |    return 201 + ArticleDto
       | <----------- 201 Created ----------|
       |  -> .subscribe next                |
       |  -> router navigate /articles      |
       |                                    |
       v                                    v

Cele două procese, două responsabilități clare:

  • Frontend = afișează formular, validează (UX), construiește DTO, gestionează navigarea după răspuns
  • Backend = autentică (JWT), autorizează (ownership), validează (DataAnnotations pe DTO), persistă (EF Core), returnează DTO

Aceeași separare de la Lab 11 — Lab 12 doar adaugă scriere peste citire.


Parte 1 — Servicii: extindem ArticleService, adăugăm CategoryService

Toate operațiile peste API trec prin servicii (singletons cu @Injectable({ providedIn: 'root' })). Componentele nu apelează HttpClient direct — apelează service-ul, care expune Observable<T>. La fel ca în Lab 6 unde controllerele apelau service-ul, nu repository-ul.

HttpClient — ce metode folosim

Lab 11 a folosit doar get. Acum adăugăm trei:

Method Signature Returnează Status code tipic
get<T>(url) URL Observable<T> 200
post<T>(url, body) URL + body JSON Observable<T> (resursa creată) 201
put<T>(url, body) URL + body JSON Observable<T> sau Observable<void> 204 (No Content)
delete<T>(url) URL Observable<T> sau Observable<void> 204

Backend-ul nostru:

  • POST /api/articles returnează 201 + ArticleDto (cu Id generat). Folosim Observable<Article>.
  • PUT /api/articles/{id} returnează 204 No Content. Folosim Observable<void> — nu așteptăm body.
  • DELETE /api/articles/{id} returnează 204 No Content. La fel, Observable<void>.

Notă: Observable<void> nu înseamnă că next nu se cheamă — se cheamă cu undefined. Pattern-ul de consum e identic: .subscribe({ next: () => ..., error: ... }). Diferența e doar tipul.

DTOs — ce trimitem la POST și PUT

În shared/models/article.ts aveți deja interfețele:

export interface CreateArticleDto {
  title: string;
  content: string;
  categoryId: number;
}

export interface UpdateArticleDto {
  title: string;
  content: string;
  categoryId: number;
}

Sunt identice ca formă, dar le ținem separate ca să fie ușor de extins independent (de exemplu, dacă Update capătă în viitor Status sau Reason).

Backend-ul are perechi corespunzătoare:

// Lab12_start/backend/DTOs/CreateArticleDto.cs
public record CreateArticleDto(
    [Required, MinLength(5)]   string Title,
    [Required, MinLength(20)]  string Content,
    [Required]                 int CategoryId);

// UpdateArticleDto - identic

ASP.NET Core face binding automat din JSON la record — exact aceleași validări ([Required], [MinLength]) care în Lab 5 erau pe ViewModels.

ArticleService — adăugăm create / update / delete

Plecăm de la ce era în Lab 11 și adăugăm trei metode:

src/app/core/services/article.ts:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import {
  Article,
  CreateArticleDto,
  UpdateArticleDto
} from '../../shared/models/article';
import { environment } from '../../../environments/environment';

@Injectable({ providedIn: 'root' })
export class ArticleService {
  private apiUrl = `${environment.apiUrl}/api/articles`;

  constructor(private http: HttpClient) {}

  getAll(): Observable<Article[]> {
    return this.http.get<Article[]>(this.apiUrl);
  }

  getById(id: number): Observable<Article> {
    return this.http.get<Article>(`${this.apiUrl}/${id}`);
  }

  create(dto: CreateArticleDto): Observable<Article> {
    // TODO Lab 12 (Ex 1)
    throw new Error('TODO Lab 12: implementati create()');
  }

  update(id: number, dto: UpdateArticleDto): Observable<void> {
    // TODO Lab 12 (Ex 1)
    throw new Error('TODO Lab 12: implementati update()');
  }

  delete(id: number): Observable<void> {
    // TODO Lab 12 (Ex 1)
    throw new Error('TODO Lab 12: implementati delete()');
  }
}

Cele trei stub-uri compilează (TypeScript permite throw cu return type Observable<X> pentru că never e asignabil la orice). Aplicația rulează — doar că la prima încercare de submit primiți eroarea „TODO Lab 12…". Asta e intenționat: skeletonul vă lasă codul rulabil, dar feature-ul deconectat până-l implementați.

Ce trebuie să scrieți (Ex 1)

Înlocuiți throw new Error(...) cu un singur apel this.http.<verb>(...):

// create
return this.http.post<Article>(this.apiUrl, dto);

// update
return this.http.put<void>(`${this.apiUrl}/${id}`, dto);

// delete
return this.http.delete<void>(`${this.apiUrl}/${id}`);

Atât. Service-ul nu subscribe — întoarce Observable-ul, componenta îl consumă. Subscription-ul e responsabilitatea componentei (sau a async pipe în template).

Notă: De ce nu await? Pentru că HttpClient returnează Observable, nu Promise. RxJS are operatori care fac compunere de stream-uri (combina, retry, debounce) — Promise-ul nu. În subscribe({ next, error }) se rezolvă echivalentul try/catch din async/await.

CategoryService — nou, simplu

Formularul de articol are dropdown de categorii. Trebuie populat cu lista de la /api/categories. Endpoint-ul există deja în Lab12_start/backend/Controllers/Api/CategoriesApiController.cs (pre-applied — nu-l scrieți voi).

src/app/core/services/category.ts:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Category } from '../../shared/models/article';
import { environment } from '../../../environments/environment';

@Injectable({ providedIn: 'root' })
export class CategoryService {
  private apiUrl = `${environment.apiUrl}/api/categories`;

  constructor(private http: HttpClient) {}

  getAll(): Observable<Category[]> {
    // TODO Lab 12 (Ex 1)
    throw new Error('TODO Lab 12: implementati getAll()');
  }
}

Backend-ul răspunde cu un array de { id, name } — exact forma Category din shared/models/article.ts. Implementarea e o linie:

return this.http.get<Category[]>(this.apiUrl);

De ce un service separat și nu metodă pe ArticleService?

Pentru că Category și Article sunt resurse diferite. Un service per resursă e convenția REST adoptată în Angular — analog cu ICategoryService / IArticleService pe backend. Dacă mâine adăugați TagService pentru /api/tags, structura e deja coerentă.

Plus: CategoryService nu trebuie să știe despre articole, și invers. Componentele care au nevoie de ambele (formularul) injectează ambele.


Exercițiul 1 (1p) — implementare servicii

Cerințe

  1. În src/app/core/services/article.ts, înlocuiți cele trei throw new Error('TODO Lab 12...') cu apeluri reale către HttpClient:

    • create(dto)POST /api/articles cu dto în body, returnează Observable<Article>
    • update(id, dto)PUT /api/articles/{id} cu dto în body, returnează Observable<void>
    • delete(id)DELETE /api/articles/{id}, returnează Observable<void>
  2. În src/app/core/services/category.ts, înlocuiți throw new Error(...) cu un GET /api/categories, returnează Observable<Category[]>.

  3. Verificare rapidă din DevTools Console (cu backend-ul și ng serve pornite, autentificat ca Admin):

    // Verificare GET categories (public, fara token)
    fetch('https://localhost:7001/api/categories').then(r => r.json()).then(console.log)
    
    // Verificare POST cu token (din DevTools, dupa login)
    const token = localStorage.getItem('token');
    fetch('https://localhost:7001/api/articles', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`
      },
      body: JSON.stringify({
        title: 'Test din DevTools',
        content: 'Continut suficient de lung pentru validare 20 chars',
        categoryId: 1
      })
    }).then(r => r.json()).then(console.log)
    

    Primul fetch trebuie să întoarcă lista de categorii. Al doilea trebuie să întoarcă articolul creat cu id populat. Dacă vedeți 401, login-ul nu a salvat token-ul (sau ați făcut logout); dacă 400, dto-ul nu trece validarea (verificați lungimea); dacă 403, backend-ul vede user-ul dar nu-i Admin / nu-i autorul (la POST nu poate apărea 403[Authorize] întoarce 401 dacă nu-i logat, 200 altfel).

Output așteptat

  • Cele 4 metode implementate, fiecare cu un singur return this.http.<verb>(...).
  • npx ng build --configuration=development rulează clean (zero erori TS).
  • Verificarea din DevTools întoarce datele așteptate de la backend.