Retourner à : Angular en 2026
Lors du chapitre précédent, nous avons vu comment nous authentifier vis-à-vis d’un serveur et comment gérer des informations d’authentification. Aujourd’hui, nous allons voir comment intégrer une API REST à notre application de gestion de collections, afin de créer et manipuler des objets de collections qui seront enregistrées sur un serveur.
Dans ce cours, on va:
- voir ce qu’est une API REST;
- présenter les API que nous allons intégrer;
- et pour finir, on regardera comment adapter notre code afin d’utiliser « HttpClient » pour interagir avec cette API.
Des bases en RxJS sont recommandées pour suivre ce cours. Bien qu’elles ne soient pas strictement nécessaires pour comprendre le chapitre, des connaissances de base de ce qu’est un Observable, un Subscriber, la méthode pipe ainsi que quelques opérateurs tels que takeUntilDestroy, map, tap et switchMap pourront être utile afin de mieux comprendre les différents exemples.
Afin d’installer les APIs utilisées dans ce cours, il faudra que vous installiez la toute dernière version de l’API, tel qu’indiqué dans le chapitre 12. Si vous avez déjà installé le backend, vérifiez bien qu’il n’y ait pas une version plus récente disponible sur GitLab avant de poursuivre ce chapitre.

API REST – Définition
Commençons par expliquer ce qu’est une API REST.
REST (REpresentationalState Transfer) est un style architectural pour concevoir des services web. Une API REST est donc une API qui respecte les principes définis par ce style.
Le principe fondamental est que l’application manipule des ressources, chacune identifiée de manière unique par une URL. Lorsqu’un client interagit avec une ressource (par exemple, pour la consulter ou la modifier), le serveur et le client se transfèrent une représentation de l’état de cette ressource, via des requêtes HTTP. Les données sont le plus souvent représentées en JSON, mais d’autres formats comme XML ou YAML sont également possibles.
Notez que dans nos exemples, on va utiliser le format JSON.
Pour illustrer tout cela, regardons l’API que nous allons utiliser pour ce cours :

Ici, on peut voir deux ressources, collections et items avec plusieurs méthodes HTTP qu’on pourra utiliser pour interagir avec ces ressources. Pour chaque ressource, on trouve les méthodes HTTP suivantes:
| Méthode HTTP | Description |
|---|---|
| GET /<resources> | Retourne toutes les ressources. |
| POST /<resources> | Permet de créer une nouvelle ressource. |
| GET /<resources>/{id} | Retourne une ressource spécifique. |
| PUT /<resources>/{id} | Permet de mettre à jour une ressource, en remplaçant entièrement la version précédente de la ressource par une nouvelle version. |
| PATCH /<resources>/{id} | Permet de mettre à jour des attributs spécifiques d’une ressource. |
| DELETE /<resources>/{id} | Permet d’effacer une ressource. |
Dans ce cours, on va utiliser les APIs suivantes :
| API | Description |
|---|---|
| GET /collections | Retourne toutes les collections |
| GET /collections/{id} | Retourne une collection précise et ces objets de collections. |
| GET /items/{id} | Retourne un objet de collection précis |
| PUT /items/{id} | Permet de modifier un objet de collection |
| DELETE /items/{id} | Permet d’effacer un objet de collection |
Préparation du code
Maintenant que nous connaissons les APIs que nous allons intégrer, il est temps de modifier un peu notre code. Tout d’abord, on va créer des interfaces avec les structures de données que définies par les APIs de notre backend. Pour cela, on va créer un nouveau dossier ‘interfaces’ à l’intérieur duquel va créer deux nouveaux fichiers. Le premier va s’appeler ‘collection-dto.ts’ et va contenir l’interface ICollectionDTO qui représente une Collection tel que définie par le backend :
import { ICollectionItemDTO } from "./collection-item-dto";
export interface ICollectionDTO {
id?: number;
title: string;
items?: ICollectionItemDTO[];
itemsCount?: number;
}Et puis, on aura également un fichier ‘collection-item-dto.ts’ qui contiendra l’interface ICollectionItemDTO :
import { Rarity } from "../models/collection-item";
export interface ICollectionItemDTO {
id?: number;
name: string;
description: string;
image: string;
rarity: Rarity;
price: number;
collectionId: string;
}Maintenant on va également adapter nos modèles, afin d’y ajouter une méthode pour convertir les données envoyées par le server en données utilisables par notre frontend, et une deuxième méthode pour convertir nos données frontend, en données utilisables par le backend. On va appeler ces méthodes fromDTO et toDTO.
Commençons par notre modèle CollectionItem. En plus des deux méthodes fromDTO et toDTO, on va également lui ajouter une référence à l’identifiant de la collection à laquelle notre objet de collection appartient :
import { ICollectionItemDTO } from "../interfaces/collection-item-dto";
export const Rarities = {
Legendary: 'Legendary',
Rare: 'Rare',
Uncommon: 'Uncommon',
Common: 'Common',
} as const;
export type Rarity = typeof Rarities[keyof typeof Rarities];
export class CollectionItem {
id?: number;
name = "";
description = "";
image = "";
rarity: Rarity = "Common";
price = 0;
collectionId: number = -1;
copy(): CollectionItem {
return Object.assign(new CollectionItem(), this);
}
static fromDTO(collectionData: ICollectionItemDTO) {
const item: CollectionItem = Object.assign(new CollectionItem(), collectionData);
item.collectionId = parseInt(collectionData.collectionId);
return item;
}
toDTO(): ICollectionItemDTO {
return {
name: this.name,
description: this.description,
image: this.image,
rarity: this.rarity,
price: this.price,
collectionId: String(this.collectionId)
};
}
}En ce qui concerne le modèle Collection, on va aussi lui ajouter les deux méthodes fromDTO et toDTO, ainsi que l’attribut itemsCount renvoyé par le backend, car on en aura besoin pour la suite :
import { ICollectionDTO } from "../interfaces/collection-dto";
import { CollectionItem } from "./collection-item";
export class Collection {
id?: number;
title: string = "My Collection";
items: CollectionItem[] = [];
itemsCount?: number;
copy(): Collection {
const copy = Object.assign(new Collection(), this);
copy.items = this.items.map(item => item.copy());
return copy;
}
static fromDTO(collectionData: ICollectionDTO) {
return Object.assign(new Collection(), {
...collectionData,
items: collectionData.items?.map(
item => CollectionItem.fromDTO(item)
)
});
}
toDTO(): ICollectionDTO {
return {
title: this.title
};
}
}Intégration de l’API :
Maintenant que nos modèles et interfaces sont prêts, nous pouvons nous lancer dans la création et modifications des services qui vont interagir avec notre API. On va commencer par le service CollectionService.
Ici on va implémenter deux méthodes:
– la méthode getAll qui va retourner un Observable de Collection[],
– et la méthode get qui prend un identifiant de collection en paramètre et retourne un Observable de Collection.
On ne va pas implémenter les méthodes add, update et delete, car elles feront l’objet d’un exercice final qui aura pour objectif de valider tout ce qu’on a appris dans ce cours Angular.
Si on regarde la documentation de l’API collections, on voit deux GET. Le premier est un GET /collections, qui retourne une liste de collections composées d’un titre (title) et du nombre d’objets de collections contenus dans celle-ci (itemsCount).

Le deuxième GET est un GET /collections/{id}, qui retourne une collection précise ainsi que la liste des objets de collections (items).

Pour effectuer un get avec HttpClient, il faut utiliser la méthode get à laquelle on passe l’URL en question en paramètre. Nos appels API GET /collections et GET /collections/{id} vont retourner respectivement des données de type ICollectionDTO[] et ICollectionDTO. Comme nous souhaitons retourner des objets de type Collection, on va utiliser la méthode pipe, à laquelle on peut passer l’opérateur RxJS map en paramètre, qui va convertir les données ICollectionDTO en Collection en utilisant la méthode fromDTO de la classe Collection:
import { inject, Injectable, signal } from '@angular/core';
import { Collection } from '../../models/collection';
import { HttpClient } from '@angular/common/http';
import { map, Observable } from 'rxjs';
import { ICollectionDTO } from '../../interfaces/collection-dto';
@Injectable({
providedIn: 'root'
})
export class CollectionService {
private baseURL = 'http://localhost:3000';
private collectionsEndpoint = this.baseURL + '/collections';
private http = inject(HttpClient);
selectedCollection = signal<Collection | null>(null);
getAll(): Observable<Collection[]> {
return this.http.get<ICollectionDTO[]>(this.collectionsEndpoint).pipe(
map(collectionJsonArray => {
return collectionJsonArray.map(
collectionJson => Collection.fromDTO(collectionJson)
);
})
);
}
get(collectionId: number): Observable<Collection> {
const url = `${this.collectionsEndpoint}/${collectionId}`;
return this.http.get<ICollectionDTO>(url).pipe(
map(collectionJson => Collection.fromDTO(collectionJson))
);
}
}
En plus des méthodes getAll et get, nous avons aussi créé un signal selectedCollection, qui sera utilisé afin de partager la collection que l’utilisateur a sélectionné à tous les composants qui en ont besoin.
Maintenant que le service CollectionService est implémenté, créons un service dédié aux objets de collections :
ng g s services/collection-item/collection-item-serviceDans ce service on va implémenter les méthodes getAll, get, add, update et delete. Pour getAll et get, on va utiliser les appels APIs GET /items et GET /items/{id} de manière identique à ce que nous venons de voir pour le CollectionService.
import { HttpClient, httpResource, HttpResourceRef } from '@angular/common/http';
import { inject, Injectable, Signal } from '@angular/core';
import { ICollectionItemDTO } from '../../interfaces/collection-item-dto';
import { map, Observable } from 'rxjs';
import { CollectionItem } from '../../models/collection-item';
@Injectable({
providedIn: 'root',
})
export class CollectionItemService {
private baseURL = 'http://localhost:3000';
private itemsEndpoint = this.baseURL + '/items';
private http = inject(HttpClient);
getAll(): Observable<CollectionItem[]> {
return this.http.get<ICollectionItemDTO[]>(this.itemsEndpoint).pipe(
map(itemJsonArray => {
return itemJsonArray.map(
itemJson => CollectionItem.fromDTO(itemJson)
);
})
);
}
get(itemId: number): Observable<CollectionItem> {
const url = `${this.itemsEndpoint}/${itemId}`;
return this.http.get<ICollectionItemDTO>(url).pipe(
map(itemJson => CollectionItem.fromDTO(itemJson))
);
}
}Regardons les autres méthodes plus en détail. Pour la méthode add, on va pouvoir utiliser l’appelle POST /items :

POST /items prend en paramètre un json avec la structure que nous l’avons définie dans notre interface ICollectionItemDTO. Pour exécuter ce POST, on peut utiliser la méthode post de HttpClient, qui prend en tant que premier paramètre une URL et en tant que deuxième paramètre les données que nous souhaitons transmettre.
Comme dans notre exemple, on manipule des objets de type CollectionItem, on va passer un CollectionItem en tant que paramètre de la méthode add, et on va devoir le convertir en ICollectionItemDTO avant de transmettre les données au serveur.
...
add(item: CollectionItem): Observable<void> {
return this.http.post<void>(this.itemsEndpoint, item.toDTO());
}
...De manière similaire, PUT /items/{id} permet de mettre à jour en objet de collection en passant les nouvelles valeurs de celui-ci en paramètre de l’appel API :

Exécuter un PUT est très similaire à un POST, à la seule différence que cette fois-ci on utilise la méthode put de HttpClient.
...
update(item: CollectionItem): Observable<void> {
const url = `${this.itemsEndpoint}/${item.id}`;
return this.http.put<void>(url, item.toDTO());
}
...Pour finir, on va encore utiliser l’appel DELETE /items/{id} afin d’effacer un objet de collection :

Ici, on peut faire appel à la méthode delete de HttpClient :
...
delete(item: CollectionItem): Observable<void> {
const url = `${this.itemsEndpoint}/${item.id}`;
return this.http.delete<void>(url);
}
...Ce qui nous donne le code complet suivant pour notre CollectionItemService :
import { HttpClient, httpResource, HttpResourceRef } from '@angular/common/http';
import { inject, Injectable, Signal } from '@angular/core';
import { ICollectionItemDTO } from '../../interfaces/collection-item-dto';
import { map, Observable } from 'rxjs';
import { CollectionItem } from '../../models/collection-item';
@Injectable({
providedIn: 'root',
})
export class CollectionItemService {
private baseURL = 'http://localhost:3000';
private itemsEndpoint = this.baseURL + '/items';
private http = inject(HttpClient);
getAll(): Observable<CollectionItem[]> {
return this.http.get<ICollectionItemDTO[]>(this.itemsEndpoint).pipe(
map(itemJsonArray => {
return itemJsonArray.map(
itemJson => CollectionItem.fromDTO(itemJson)
);
})
);
}
get(itemId: number): Observable<CollectionItem> {
const url = `${this.itemsEndpoint}/${itemId}`;
return this.http.get<ICollectionItemDTO>(url).pipe(
map(itemJson => CollectionItem.fromDTO(itemJson))
);
}
add(item: CollectionItem): Observable<void> {
return this.http.post<void>(this.itemsEndpoint, item.toDTO());
}
update(item: CollectionItem): Observable<void> {
const url = `${this.itemsEndpoint}/${item.id}`;
return this.http.put<void>(url, item.toDTO());
}
delete(item: CollectionItem): Observable<void> {
const url = `${this.itemsEndpoint}/${item.id}`;
return this.http.delete<void>(url);
}
}
Simplification du code
Maintenant que nos services sont prêts, utilisons-les. Pour commencer, créons un composant dédié au menu d’affichage et de selection de collections. Pour le moment, ce menu n’affichera que la collection par défaut. En plus de cela, le menu va effectuer les choses suivantes :
- Quand une collection est sélectionnée, l’identifiant de celle-ci est stockée dans le localStorage
- Lors du chargement de la page, le menu vérifie si une collection sélectionnée est stockée dans le localStorage.
Si c’est le cas, le menu la charge et la sélectionne. Si aucune collection n’est sélectionnée, le menu charge toutes les collections et sélectionne la première collection de la liste.
import { Component, effect, inject } from '@angular/core';
import { Router } from '@angular/router';
import { CollectionService } from '../../services/collection/collection-service';
import { LoginService } from '../../services/login/login-service';
import { MatButtonModule } from '@angular/material/button';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-main-menu',
imports: [MatButtonModule],
templateUrl: './main-menu.html',
styleUrl: './main-menu.scss',
})
export class MainMenu {
private readonly LK_SELECTED_COLLECTION = "selectedCollection";
private readonly loginService = inject(LoginService);
private readonly collectionService = inject(CollectionService);
private readonly router = inject(Router);
protected readonly user = this.loginService.user;
readonly collections = toSignal(this.collectionService.getAll(), {initialValue: []});
readonly selectedCollection = this.collectionService.selectedCollection;
constructor() {
effect(() => {
if (this.collections()?.length) {
this.loadSelectedCollection();
}
});
}
logout() {
this.loginService.logout().pipe(
takeUntilDestroyed()
).subscribe({
next: () => this.router.navigate(['login']),
error: () => this.router.navigate(['login']),
});
}
select(selectedCollectionId: number) {
if (selectedCollectionId) {
localStorage.setItem(this.LK_SELECTED_COLLECTION, String(selectedCollectionId));
this.collectionService.get(selectedCollectionId).subscribe(collection => {
this.selectedCollection.set(collection);
this.router.navigate(['collection', collection.id]);
});
}
}
loadSelectedCollection() {
const storedCollection = localStorage.getItem(this.LK_SELECTED_COLLECTION);
let identifiedCollection = null;
if (storedCollection) {
identifiedCollection = this.collections().find(c => c.id === parseInt(storedCollection));
}
if (!identifiedCollection) {
identifiedCollection = this.collections()[0];
}
if (identifiedCollection.id) {
this.collectionService.get(identifiedCollection.id).subscribe(collection => {
this.selectedCollection.set(collection);
if (this.router.url === '/collection') {
this.router.navigate(['collection', collection.id]);
}
});
}
}
}Le rendu du menu, qui était jusqu’à présent géré dans le fichier « app.html », doit maintenant être placé dans le fichier « main-menu.html » :
@let currentUser = user();
@if (currentUser) {
<nav>
<header><span class="avatar">{{currentUser.firstname.charAt(0)}}</span>{{currentUser.firstname}} {{currentUser.lastname}}</header>
<ul>
@let collectionList = collections() || [];
@for (collection of collectionList; track collection.id) {
<li class="selected">
<div class="dot"></div>
<div class="collection">
<div class="name">Default Collection</div>
<div class="items-count">X items</div>
</div>
</li>
}
</ul>
<footer>
<button matButton="filled" class="danger" (click)="logout()">Logout</button>
</footer>
</nav>
}Transférons aussi le css du menu dans le fichier « main-menu.scss » :
nav {
display: flex;
flex-direction: column;
padding: 1rem;
height: calc(100vH - 2rem);
}
nav ul {
flex-grow: 1;
list-style: none;
padding: 0;
width: 250px;
}
nav ul li {
width: calc(100% - 2rem);
padding: 1rem;
border: 2px solid lightgray;
border-radius: 0.5rem;
display: flex;
align-items: center;
}
nav ul li.selected {
border-color: cadetblue;
background-color: #9999ff25;
}
.avatar {
display: inline-block;
width: 2rem;
height: 2rem;
line-height: 2rem;
text-align: center;
border-radius: 2.5rem;
border: 2px solid grey;
vertical-align: baseline;
margin-right: 0.5rem;
}
nav header {
vertical-align: baseline;
font-size: large;
font-weight: bold;
}
nav footer button {
width: 100%;
}
.dot {
display: inline-block;
width: 1rem;
height: 1rem;
background-color: crimson;
border-radius: 0.5rem;
margin-right: 0.5rem;
}
.collection {
display: flex;
flex-direction: column;
}
.collection .name {
font-weight: bolder;
}
.collection .items-count {
font-size: smaller;
}Nous pouvons maintenant adapter nos fichiers « app.html », « app.ts » et « app.scss » afin d’y utiliser le composant MainMenu. Commençons par adapter le HTML :
<div id="page-container">
@let currentUser = user();
@if (currentUser) {
<app-main-menu></app-main-menu>
}
<main>
<router-outlet></router-outlet>
</main>
</div>Ensuite, importons MainMenu dans « app.ts » et enlevons tout le code qui n’est plus nécessaire :
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterOutlet } from "@angular/router";
import { LoginService } from './services/login/login-service';
import { MainMenu } from "./components/main-menu/main-menu";
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, MainMenu]
})
export class App {
private loginService = inject(LoginService);
protected user = this.loginService.user;
}Pour finir, enlevons tout le css spécifique au menu qui n’a plus besoin de se trouver dans le fichier « app.scss » :
#page-container {
display: flex;
width: 100vW;
height: 100vH;
}
main {
flex-grow: 1;
height: 100vH;
}Adaptation des routes
Nous allons maintenant adapter nos routes, en enlevant la route home et en la remplaçant par une route plus parlante qu’on va appeler collection. On va également avoir une sous-route collection/:id qui sera utilisée pour afficher une collection spécifique :
import { Routes } from '@angular/router';
import { CollectionDetail } from './pages/collection-detail/collection-detail';
import { CollectionItemDetail } from './pages/collection-item-detail/collection-item-detail';
import { NotFound } from './pages/not-found/not-found';
import { LoginComponent } from './pages/login/login';
import { isLoggedInGuard } from './guards/is-logged-in/is-logged-in-guard';
export const routes: Routes = [{
path: '',
redirectTo: 'collection',
pathMatch: 'full'
}, {
path: 'collection',
children: [{
path: '',
component: CollectionDetail,
canActivate: [isLoggedInGuard]
}, {
path: ':id',
component: CollectionDetail,
canActivate: [isLoggedInGuard]
}]
}, {
path: 'item',
children: [{
path: '',
component: CollectionItemDetail,
canActivate: [isLoggedInGuard]
}, {
path: ':id',
component: CollectionItemDetail,
canActivate: [isLoggedInGuard]
}],
}, {
path: 'login',
component: LoginComponent
}, {
path: '**',
component: NotFound
}];
Nous devons aussi adapter notre page de login, car en cas de login réussi, nous redirigeons l’utilisateur sur la page home. Adaptons le composant afin que cette redirection se fasse vers la racine de l’application :
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { LoginCredentialsDTO, LoginService } from '../../services/login/login-service';
import { Router } from '@angular/router';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule, MatInputModule, MatButtonModule],
templateUrl: './login.html',
styleUrl: './login.scss'
})
export class LoginComponent {
private formBuilder = inject(FormBuilder);
private loginService = inject(LoginService);
private router = inject(Router);
private subscriptions: Subscription = new Subscription();
loginFormGroup = this.formBuilder.group({
'username': ['', [Validators.required]],
'password': ['', [Validators.required]]
});
invalidCredentials = signal(false);
login() {
const loginSubscription = this.loginService.login(
this.loginFormGroup.value as LoginCredentialsDTO
).subscribe({
next: () => this.getUserAndRedirect(),
error: () => this.invalidCredentials.set(true)
});
this.subscriptions.add(loginSubscription);
}
getUserAndRedirect() {
const getUserSubscription = this.loginService.getUser().subscribe(user => {
this.navigateHome();
});
this.subscriptions.add(getUserSubscription);
}
navigateHome() {
this.router.navigate(['/']);
}
ngOnDestroy(): void {
this.subscriptions.unsubscribe();
}
}Utilisation des services afin d’interagir avec le backend
Il est temps maintenant d’adapter nos composants CollectionDetail et CollectionItemDetail afin qu’ils utilisent nos différents services pour interagir avec le backend. Commençons par CollectionDetail.
La première chose à modifier, est que nous allons maintenant recevoir l’identifiant de la collection à afficher en tant qu’input via la route collection/:id. Nous allons donc devoir charger la collection à afficher en utilisant cet identifiant. Pour cela, on va faire plusieurs choses :
- créer un input collectionId qui aura pour alias l’id de la collection et qui va convertir l’id en number;
- on va créer un Observable qu’on va appeler selectedCollection$ à partir de l’input collectionId. On va faire appel à la méthode pipe sur cette Observable afin d’enchainer plusieurs opérateurs RxJS:
- Tout d’abord, on va utiliser l’opérateur takeUntilDestroyed qui va se charger de se désabonner de l’Observable quand notre composant sera détruit.
- Nous allons ensuite filtrer uniquement les cas où un id est passé en paramètre avec l’opérateur filter.
- Ensuite, on va faire appel à switchMap pour prendre cet identifiant. Puis, on utilisera la méthode get du CollectionService pour récupérer la collection à partir du serveur. Enfin, on retournera un Observable qui retournera la collection en question.
- Pour finir, nous allons stocker la collection dans le signal selectedCollection.
- nous allons également adapter le constructeur de CollectionDetail afin d’y exécuter le code suivant :
- On va créer un effect qui va vérifier si un identifiant de collection a bien été passé en input. Si ce n’est pas le cas, l’effect va rediriger l’utilisateur vers la collection qui a été sélectionnée par le menu.
- Pour terminer, on va également faire un subscribe à selectedCollection$
Une fois tout cela fait, on se retrouve avec le code suivant :
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, model, signal } from '@angular/core';
import { SearchBar } from "../../components/search-bar/search-bar";
import { CollectionService } from '../../services/collection/collection-service';
import { CollectionItemCard } from '../../components/collection-item-card/collection-item-card';
import { CollectionItem } from '../../models/collection-item';
import { Router } from '@angular/router';
import { MatButtonModule } from "@angular/material/button";
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { filter, switchMap, tap } from 'rxjs';
import { Collection } from '../../models/collection';
@Component({
selector: 'app-collection-detail',
imports: [CollectionItemCard, SearchBar, MatButtonModule],
templateUrl: './collection-detail.html',
styleUrl: './collection-detail.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CollectionDetail {
private readonly router = inject(Router);
private readonly collectionService = inject(CollectionService);
search = model('');
collectionId = input<number | undefined, string | undefined>(undefined, {
alias: 'id',
transform: ((id: string | undefined) => id ? parseInt(id) : undefined)
});
selectedCollection$ = toObservable(this.collectionId).pipe(
takeUntilDestroyed(),
filter(id => id !== undefined),
switchMap(id => this.collectionService.get(id)),
tap(collection => {
this.selectedCollection.set(collection);
})
)
selectedCollection = signal<Collection>(new Collection());
displayedItems = computed(() => {
const allItems = this.selectedCollection()?.items || [];
return allItems.filter(item =>
item.name.toLowerCase().includes(
(this.search() || '').toLocaleLowerCase()
)
);
});
constructor() {
effect(() => {
if (!this.collectionId() && this.collectionService.selectedCollection()) {
this.router.navigate(['collection', this.collectionService.selectedCollection()?.id])
}
})
this.selectedCollection$.subscribe();
}
addItem() {
this.router.navigate(['item']);
}
openItem(item: CollectionItem) {
this.router.navigate(['item', item.id]);
}
}Adaptons maintenant le fichier « collection-detail.html » afin de n’afficher le contenu de la page que si une collection a bien été sélectionnée et chargée du backend :
@let collection = selectedCollection();
@if (collection) {
<header id="collection-header">
<h1>{{collection.title}}</h1>
<div>
<app-search-bar
[(search)]="search"
>
</app-search-bar>
</div>
</header>
<section class="collection-grid">
@for (item of displayedItems(); track item.name) {
@switch (item.rarity) {
@case ("Legendary") {
<div>
<app-collection-item-card [item]="item" (click)="openItem(item)"></app-collection-item-card>
<hr class="gold">
</div>
}
@case ("Rare") {
<div>
<app-collection-item-card [item]="item" (click)="openItem(item)"></app-collection-item-card>
<hr class="dashed">
</div>
}
@default {
<app-collection-item-card [item]="item" (click)="openItem(item)"></app-collection-item-card>
}
}
}
</section>
@let displayedItemsCount = displayedItems().length;
@if(displayedItemsCount > 0) {
<div class="centered">{{displayedItemsCount}} objet(s) affiché(s).</div>
} @else {
<div class="centered">Aucun résultat.</div>
}
<div class="centered">
<button matButton="filled" (click)="addItem()">Ajouter Objet</button>
</div>
}Il ne nous reste plus qu’à adapter le composant CollectionItemDetail afin de l’intégrer lui aussi avec notre backend. Pour cela, on va faire les choses suivantes :
- On va commencer par injecter le CollectionItemService
...
export class CollectionItemDetail {
...
private readonly collectionService = inject(CollectionService);
...
}- Nous aurons un input itemId qui va avoir en tant qu’alias id et qui pourra soit avoir un nombre, si l’utilisateur souhaite modifier un object de collection existant, soit être undefined, si l’utilisateur souhaite créer un nouvel objet.
...
itemId = input<number | undefined, string | undefined>(undefined, {
alias: 'id',
transform: ((id: string | undefined) => id ? parseInt(id) : undefined)
});
...- Ensuite, nous allons créer un linkedSignal qui va avoir comme valeur le signal selectedCollection du CollectionService, et qui est donc la collection sélectionnée via le menu.
...
selectedCollection = linkedSignal(() => this.collectionService.selectedCollection());
...- Nous allons également créer un Observable collectionItem$, qui va convertir l’input itemId en Observable et utiliser la méthode pipe afin d’enchaîner les opérations suivantes :
- takeUntilDestroyed, qui va se charger de se désabonner de l’Observable quand notre composant sera détruit;
- on filtre uniquement les cas où un id est passé en paramètre avec l’opérateur filter;
- avec switchMap, on va récupérer l’objet avec l’id itemId grâce à la méthode get de CollectionItemService;
- pour finir, on fait un tap, qui va assigner l’objet ainsi récupéré à notre signal collectionItem, et on va également utiliser ces valeurs dans notre itemFormGroup afin d’afficher l’objet dans le formulaire.
...
collectionItem$ = toObservable(this.itemId).pipe(
takeUntilDestroyed(),
filter(itemId => itemId !== undefined),
switchMap(itemId => this.collectionItemService.get(itemId)),
tap(item => {
this.collectionItem.set(item);
this.itemFormGroup.patchValue(item);
}),
)
...- Ensuite, si on charge un item à partir d’un identifiant, on va aussi récupérer la collection associée à cet objet. Pour cela, on va créer un nouvel Observable itemCollection$ qui va se charger de récupérer la collection en question en prenant l’Observable collectionItem$ et en utilisant la méthode pipe de celui-ci afin d’enchaîner les opérations suivantes :
- takeUntilDestroyed qui va se charger de se désabonner de l’Observable quand notre composant sera détruit
- avec switchMap. On va récupérer la collection associée au collectionId de l’objet en utilisant la méthode get du CollectionService.
- si une erreur est renvoyée par le server, on navigue vers la page principale et on retourne un EMPTY
- pour finir, si on a bien récupéré la collection associée à l’objet de collection, on l’assigne à notre signal selectedCollection.
...
itemCollection$ = this.collectionItem$.pipe(
takeUntilDestroyed(),
switchMap(item => this.collectionService.get(item.collectionId)),
catchError(error => {
this.navigateBack();
return EMPTY;
}),
tap(collection => {
this.selectedCollection.set(collection);
})
)
...- On va encore créer un dernier Observable formValuesChanges$, qui va prendre l’Observable valueChanges de itemFormGroup et utiliser la méthode pipe de celui-ci afin d’appliquer les opérateurs suivants :
- takeUntilDestroyed qui va se charger de se désabonner de l’Observable quand notre composant sera détruit;
- un tap qui va assigner les valeurs du formulaire au signal collectionItem. En plus de ces valeurs, on va aussi assigner l’identifiant (id) de l’objet ainsi que l’identifiant de se collection (collectionId).
...
formValueChanges$ = this.itemFormGroup.valueChanges.pipe(
takeUntilDestroyed(),
tap(_ => {
this.collectionItem.set(Object.assign(new CollectionItem(), {
...this.itemFormGroup.value,
id: this.itemId(),
collectionId: this.selectedCollection()?.id
}));
})
)
...- Dans notre constructeur, on va remplacer tout le code présent par un subscribe à collectionItem$ et un autre subscribe à formValueChanges$ :
...
constructor() {
this.collectionItem$.subscribe();
this.formValueChanges$.subscribe();
}
...- Nous allons ensuite modifier la méthode submit comme ceci :
- On va récupérer l’objet à stocker à partir du signal collectionItem;
- ensuite, si l’objet a un identifiant, on va faire appel à la méthode update du CollectionItemService. Sinon on va utiliser la méthode add.
- Les deux méthodes retournent un observable auquel on va devoir faire un subscribe, et une fois que la méthode de notre subscribe est appelée, on redirige l’utilisateur sur la page principale.
...
submit(event: Event) {
event.preventDefault();
const item = this.collectionItem();
if (!item) return;
let saveObservable = null;
if (item.id) {
saveObservable = this.collectionItemService.update(item);
} else {
saveObservable =this.collectionItemService.add(item);
}
saveObservable.subscribe(() => {
this.navigateBack();
});
}
...- Pour la méthode deleteItem, on va faire appel à la méthode delete de CollectionItemService, et ici aussi, après l’appel API, on va renvoyer l’utilisateur sur la page principale.
...
deleteItem() {
const item = this.collectionItem();
if (item) {
this.collectionItemService.delete(item).subscribe(() => {
this.navigateBack();
});
}
}
...- Pour finir, on renomme la méthode cancel en navigateBack, et on efface la méthode ngOnDestroy dont on n’aura plus besoin, ce qui nous donne le code complet suivant :
import { Component, inject, input, linkedSignal, signal } from '@angular/core';
import { Router } from '@angular/router';
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { CollectionItem, Rarities } from '../../models/collection-item';
import { CollectionItemCard } from "../../components/collection-item-card/collection-item-card";
import { catchError, EMPTY, filter, switchMap, tap } from 'rxjs';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { CollectionItemService } from '../../services/collection-item/collection-item-service';
import { CollectionService } from '../../services/collection/collection-service';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-collection-item-detail',
imports: [ReactiveFormsModule, CollectionItemCard, MatButtonModule, MatInputModule, MatSelectModule],
templateUrl: './collection-item-detail.html',
styleUrl: './collection-item-detail.scss'
})
export class CollectionItemDetail {
private readonly fb = inject(FormBuilder);
private readonly router = inject(Router);
private readonly collectionItemService = inject(CollectionItemService);
private readonly collectionService = inject(CollectionService);
rarities = Object.values(Rarities);
itemId = input<number | undefined, string | undefined>(undefined, {
alias: 'id',
transform: ((id: string | undefined) => id ? parseInt(id) : undefined)
});
selectedCollection = linkedSignal(() => this.collectionService.selectedCollection());
collectionItem = signal<CollectionItem>(new CollectionItem());
itemFormGroup = this.fb.group({
name: ['', [Validators.required]],
description: ['', [Validators.required]],
image: ['', [Validators.required]],
rarity: ['', [Validators.required]],
price: [0, [Validators.required, Validators.min(0)]]
});
collectionItem$ = toObservable(this.itemId).pipe(
takeUntilDestroyed(),
filter(itemId => itemId !== undefined),
switchMap(itemId => this.collectionItemService.get(itemId)),
tap(item => {
this.collectionItem.set(item);
this.itemFormGroup.patchValue(item);
}),
)
itemCollection$ = this.collectionItem$.pipe(
takeUntilDestroyed(),
switchMap(item => this.collectionService.get(item.collectionId)),
catchError(error => {
this.navigateBack();
return EMPTY;
}),
tap(collection => {
this.selectedCollection.set(collection);
})
)
formValueChanges$ = this.itemFormGroup.valueChanges.pipe(
takeUntilDestroyed(),
tap(_ => {
this.collectionItem.set(Object.assign(new CollectionItem(), {
...this.itemFormGroup.value,
id: this.itemId(),
collectionId: this.selectedCollection()?.id
}));
})
)
constructor() {
this.collectionItem$.subscribe();
this.formValueChanges$.subscribe();
}
submit(event: Event) {
event.preventDefault();
const item = this.collectionItem();
if (!item) return;
let saveObservable = null;
if (item.id) {
saveObservable = this.collectionItemService.update(item);
} else {
saveObservable =this.collectionItemService.add(item);
}
saveObservable.subscribe(() => {
this.navigateBack();
});
}
deleteItem() {
const item = this.collectionItem();
if (item) {
this.collectionItemService.delete(item).subscribe(() => {
this.navigateBack();
});
}
}
navigateBack() {
this.router.navigate(['/']);
}
isFieldValid(fieldName: string) {
const formControl = this.itemFormGroup.get(fieldName);
return formControl?.invalid && (formControl?.dirty || formControl?.touched);
}
onFileChange(event: any) {
const reader = new FileReader();
if (event.target.files && event.target.files.length) {
const [file] = event.target.files;
reader.readAsDataURL(file);
reader.onload = () => {
this.itemFormGroup.patchValue({
image: reader.result as string
});
};
}
}
}Il ne nous reste plus qu’à renommer l’appel à la fonction cancel dans le fichier collection-item-detail.html en navigateBack comme ceci :
<app-collection-item-card [item]="collectionItem()" />
<form [formGroup]="itemFormGroup" (submit)="submit($event)">
<mat-form-field class="form-field">
<mat-label for="name">Name : </mat-label>
<input matInput id="name" name="name" formControlName="name"/>
@if (isFieldValid('name')) {
<mat-error>This field is required!</mat-error>
}
</mat-form-field>
<mat-form-field class="form-field">
<mat-label for="description">Description : </mat-label>
<textarea matInput id="description" name="description" formControlName="description" rows="5"></textarea>
@if (isFieldValid('description')) {
<mat-error>This field is required!</mat-error>
}
</mat-form-field>
<mat-form-field class="form-field">
<mat-label for="rarity">Rarity</mat-label>
<mat-select id="rarity" name="rarity" formControlName="rarity">
@for (rarity of rarities; track rarity) {
<mat-option [value]="rarity">{{rarity}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label for="price">Price : </mat-label>
<input matInput id="price" name="price" formControlName="price" type="number"/>
@if (isFieldValid('price')) {
@let priceFormControl = itemFormGroup.get('price');
@if (priceFormControl?.hasError('required')) {
<mat-error>This field is required!</mat-error>
}
@if (priceFormControl?.hasError('min')) {
<mat-error>The value must be bigger or equal to 0!</mat-error>
}
}
</mat-form-field>
<div class="form-field">
<button matButton="elevated"
(click)="$event.preventDefault(); imageUploader.click();"
>
Upload Image: {{ imageUploader.files?.[0]?.name || '...' }}
</button>
<input hidden #imageUploader id="image" name="image" type="file" (change)="onFileChange($event)">
@if (isFieldValid('image')) {
<div class="error">This field is required.</div>
}
</div><br>
<div class="action-buttons">
<div class="left">
@if (this.itemId()) {
<button matButton="filled" class="danger" type="button" (click)="deleteItem()">Delete</button>
}
</div>
<div class="right">
<button matButton="text" type="button" (click)="navigateBack()">Cancel</button>
<button matButton="filled" type="submit"
[disabled]="itemFormGroup.invalid"
>
Save
</button>
</div>
</div>
</form>Et voilà, le tour est joué. Nous avons maintenant une application qui interagi avec notre backend.

Ceci conclut notre introduction à Angular. Dans le prochain chapitre, vous retrouverez un exercice pour aller plus loin et compléter les fonctionnalités de notre application par vous-même. Je vous encourage vivement à le faire, et si le cours vous a été utile, n’hésitez pas à me faire de la pub autour de vous et à me montrer votre soutien sur mes réseaux sociaux.
Ressources
Vous retrouvez ci-dessous un lien vers le code source utilisé dans ce chapitre. Ce lien est uniquement disponible pour les abonnés Standard et Premium.
Quiz
Répondez au quiz ci-dessous afin de vérifier que vous avez bien compris le contenu de cette leçon. Les quiz sont uniquement disponibles pour les abonnés Standard et Premium.
Mais pour ce cours