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:
ArticleServiceextins —create(),update(),delete()cuHttpClient.post/put/deleteși RxJSObservable<void>pentru operațiile fără body de răspunsCategoryServicenou — un singurgetAll(), alimentează dropdown-ul din formularArticleFormComponent— un singur component pentru create + edit (route param:iddistinge cazurile), cu reactive forms:FormBuilder,Validators, mesaje de validare per câmp- Routing nou —
/articles/newși/articles/:id/edit, ambele protejate cuAuthGuard - Role-based UI —
canModify(article)înArticleListComponentcare întoarcetruedoar pentru autor sau Admin; butoanele „Editează" și „Șterge" sunt randate condiționat cu*ngIf - Delete cu confirm + refresh —
confirm()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 methodarticle.ToViewModel()care înlocuiește mapping-ul inline din 5 acțiuni MVC. Aceeași idee caToDto()din Lab 8.Authorization/ClaimsPrincipalExtensions.cs—User.CanModifyArticle(article)extension, înlocuieșteIsOwnerOrAdminprivat care era duplicat în 2 controllere.Services/IJwtService.cs+JwtService.cs— generarea de JWT mutată dinAuthApiController(28 de linii erau în controller). Service-ul e înregistrat înProgram.cscuAddScoped.Controllers/Api/CategoriesApiController.cs(NOU) —GET /api/categoriesreturneazăList<CategoryDto>. Pre-scris pentru că dropdown-ul din formular ar fi blocat altfel întreg laboratorul.
Frontend (schelet pentru exerciții):
core/services/article.ts—getAll()șigetById()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 cucancel()funcțional, restul e TODO. Declarată înAppModule, î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ă hardcodatfalse— completați la Ex 4.shared/models/article.ts— adăugate interfețeleCreateArticleDto,UpdateArticleDto.app-routing.module.ts— rutele noi cuAuthGuard. Ordinea contează:articles/newînainte dearticles/:id, altfel:idar 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),ArticleDtoreturneazăList<TagDto> Tags,CreateArticleDtoșiUpdateArticleDtoacceptăList<int>? TagIds.ArticlesApiController.CreateșiUpdateaplică tag-urile viaITagService.GetByIdsAsync(PUT cu semantica „lista trimisă = lista finală"). - Frontend:
core/services/tag.ts(NOU,getAll()funcțional),shared/models/article.tsare interfațaTag { id, name }șiArticle.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}/imageacceptămultipart/form-datacu cheiafile, validează extensia și dimensiunea (max 5 MB, png/jpg/jpeg/gif/webp), scrie înwwwroot/images/article-{id}-{guid}.{ext}și seteazăArticle.ImagePath. Static files servite viaapp.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 derouter.navigate(...). Restul logicii de upload e ascuns în scaffold. - Detail + list afișează imaginea cu
<img [src]="backendUrl + a.imagePath">undebackendUrl = 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,
AuthServicecu JWT decode înlocalStorage,ArticleServiceread-only,HttpConfigInterceptorcare atașeazăAuthorization: Bearer,AuthGuard, routing, componenteLoginComponent/RegisterComponent/ArticleListComponent/ArticleDetailComponent/HeaderComponent
Lab12_start continuă fără să strice nimic. Tot ce mergea în Lab 11 merge în continuare:
dotnet runînLab12_start/backend/→ backend (porturi dinlaunchSettings.json)ng serveînLab12_start/news-portal-app/→ frontend pe4200- Login cu
admin@newsportal.com / Admin@123(seedat din Lab 7) — primește rolulAdminî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/articlesreturnează201+ArticleDto(cuIdgenerat). FolosimObservable<Article>.PUT /api/articles/{id}returnează204 No Content. FolosimObservable<void>— nu așteptăm body.DELETE /api/articles/{id}returnează204 No Content. La fel,Observable<void>.
Notă:
Observable<void>nu înseamnă cănextnu se cheamă — se cheamă cuundefined. 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ăHttpClientreturnează Observable, nu Promise. RxJS are operatori care fac compunere de stream-uri (combina, retry, debounce) — Promise-ul nu. Însubscribe({ next, error })se rezolvă echivalentultry/catchdin 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
-
În
src/app/core/services/article.ts, înlocuiți cele treithrow new Error('TODO Lab 12...')cu apeluri reale cătreHttpClient:create(dto)→POST /api/articlescudtoîn body, returneazăObservable<Article>update(id, dto)→PUT /api/articles/{id}cudtoîn body, returneazăObservable<void>delete(id)→DELETE /api/articles/{id}, returneazăObservable<void>
-
În
src/app/core/services/category.ts, înlocuițithrow new Error(...)cu unGET /api/categories, returneazăObservable<Category[]>. -
Verificare rapidă din DevTools Console (cu backend-ul și
ng servepornite, 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
idpopulat. Dacă vedeți401, 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 (laPOSTnu poate apărea403—[Authorize]întoarce401dacă nu-i logat,200altfel).
Output așteptat
- Cele 4 metode implementate, fiecare cu un singur
return this.http.<verb>(...). npx ng build --configuration=developmentrulează clean (zero erori TS).- Verificarea din DevTools întoarce datele așteptate de la backend.