8. Les services

0

Dans ce chapitre, on verra comment centraliser la gestion de données de nos applications dans un service Angular. Pour cela nous allons voir:

  • ce qu’est un service
  • comment créer un service
  • comment injecter et utiliser ce service dans nos composants
  • on implémentera un service CRUD (Create, Read, Update, Delete)
  • pour finir on utilisera localStorage pour persister des données dans le navigateur

Pour rappel, tout au long de ce cours, on va créer une application de gestion de collections de timbres, pièces, figurines, cartes à jouer ou autre.

Définition

Commençons par expliquer ce qu’est un service. Un service est une classe qui va mettre à disposition des données et/ou méthodes qui peuvent être réutilisées par plusieurs composants de notre application. Un service permet donc de séparer les données et la logique métier d’une application, de son affichage.

Un service Angular est une classe qui est un « Singleton Injectable ». « Singleton » signifie que nous n’avons qu’une seule instance du service dans toute notre application. « Injectable » veut dire que nous pouvons injecter cette instance dans les composants où nous en avons besoin. Nous verrons dans un instant comment injecter notre service.

Création d’un service

Actuellement, dans notre fichier app.ts, nous créons nos objets de collection directement dans le constructeur du composant. Cette approche n’est pas bonne car ces données pourraient être nécessaires dans d’autres composants. Il serait donc mieux de gérer ces données dans un service.

Avant de créer notre premier service, adaptons nos modèles CollectionItem et Collection en leur ajoutant un identifiant et une méthode copy(). Commençons par CollectionItem :

collection-item.ts
export class CollectionItem {

    id = -1;
    name = "Linx";
    description = "A legendary sword of unmatched sharpness and history.";
    image = "img/linx.png"
    rarity = "Legendary";
    price = 250;

    copy(): CollectionItem {
        return Object.assign(new CollectionItem(), this);
    }

}

Faisons la même chose pour notre classe Collection :

collection.ts
import { CollectionItem } from "./collection-item";

export class Collection {
    
    id = -1;
    title: string = "My Collection";
    items: CollectionItem[] = [];

    copy(): Collection {
        const copy = Object.assign(new Collection(), this);
        copy.items = this.items.map(item => item.copy());
        return copy;
    }
    
}

Note 1: La méthode Object.assign permet de copier les propriétés d’un objet source (ici: this) vers un objet de destination (ici: new CollectionItem / new Collection).

Note 2: Les objets sont copiés par référence, nous avons donc créé un nouveau tableau avec les copies des différents items de la classe Collection, autrement la copie aurait pointé sur le même tableau et une modification du tableau original aurait affecté la copie et vice-versa.

Maintenant que nous avons adaptés nos deux classes, créons notre premier service. Nous allons créer notre service dans un dossier qu’on va appeler services et nous allons l’appeler collection-service. Pour cela nous pouvons utiliser la commande ng g s <chemin d’accès>/<nom> :

Bash
ng g s services/collection-service

Pour commencer, ouvrons le fichier collection-service.ts et créons une méthode de teste, qu’on va appeler hello et qui va imprimer Hello World dans la console :

collection-service.ts
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class CollectionService {

  hello() {
    console.log("Hello World");
  }
  
}

Si on regarde le code généré par angular de plus près, on voit que le service est une classe tout à fait normale à laquelle on a ajouté le décorateur @Injectable avec le paramètre providedIn. @Injectable indique que la classe peut être injectée via le système d’injection de dépendances et providedIn: ‘root’ indique que l’instance de notre service est un singleton accessible partout dans l’application. 

Injecter un service

Maintenant que notre service a été créé, retournons dans notre fichier app.ts afin de voir comment l’y injecter et comment l’utiliser. Pour injecter notre service, on va créer un attribut qu’on va appeler collectionService, auquel on va assigner le résultat de l’appel à la méthode inject, qu’on va importer de @angular/core et à laquelle on va passer le service qu’on souhaite injecter en paramètre.

Dans notre cas on veut injecter le service CollectionService et faire appel à sa méthode hello dans le contructor de notre composant App, et on peut donc faire :

app.ts
import { Injectable } from '@angular/core';
import { Collection } from '../models/collection';
import { CollectionItem } from '../models/collection-item';

@Injectable({
  providedIn: 'root'
})
export class CollectionService {

  private collections: Collection[] = [];
  private currentId = 1;
  private currentItemIndex: {[key: number]: number} = {};

  constructor() {
    this.generateDummyData();
  }

  generateDummyData() {
    const coin = new CollectionItem();
    coin.name = 'Pièce de 1972';
    coin.description = 'Pièce de 50 centimes de francs.';
    coin.rarity = 'Commune';
    coin.image = 'img/coin1.png';
    coin.price = 170;
      
    const stamp = new CollectionItem();
    stamp.name = 'Timbre 1800';
    stamp.description = 'Un vieux timbre';
    stamp.rarity = 'Rare';
    stamp.image = 'img/timbre1.png';
    stamp.price = 555;
      
    const linx = new CollectionItem();
      
    const defaultCollection = new Collection();
    defaultCollection.title = "Collection mix";

    const storedCollection = this.add(defaultCollection);
    this.addItem(storedCollection, coin);
    this.addItem(storedCollection, linx);
    this.addItem(storedCollection, stamp);
  }

  getAll(): Collection[] {
    return this.collections.map(collection => collection.copy());
  }

  get(collectionId: number): Collection | null {
    const storedCopy = this.collections.find(
      collection => collection.id === collectionId
    );

    if (!storedCopy) return null;
    return storedCopy.copy();
  }

  add(collection: Omit<Collection, 'id' | 'items'>): Collection {
    
    const storedCopy = collection.copy();
    storedCopy.id = this.currentId;
    this.collections.push(storedCopy);

    this.currentItemIndex[storedCopy.id] = 1;
    this.currentId++;

    return storedCopy.copy();

  }

  update(collection: Omit<Collection, 'items'>): Collection | null {
   const storedCopy = this.collections.find(
    collection => collection.id === collection.id
   );

   if (!storedCopy) return null;
   
   Object.assign(storedCopy, collection);
   return storedCopy.copy();

  }

  delete(collectionId: number): void {
    this.collections = this.collections.filter(
      collection => collection.id !== collectionId
    );
  }

  addItem(collection: Collection, item: CollectionItem): Collection | null {
    const storedCollection = this.collections.find(
      collection => collection.id === collection.id
    );
    
    if (!storedCollection) return null;
    
    const storedItem = item.copy();
    storedItem.id = this.currentItemIndex[collection.id];
    storedCollection.items.push(storedItem);

    this.currentItemIndex[collection.id]++;

    return storedCollection.copy();
  }

  updateItem(collection: Collection, item: CollectionItem) {
    const storedCollection = this.collections.find(
      storedCollection => storedCollection.id === collection.id
    );
    
    if (!storedCollection) return null;

    const storedItemIndex = storedCollection.items.findIndex(
      storedItem => storedItem.id === item.id
    )

    if (storedItemIndex === -1) return null;

    storedCollection.items[storedItemIndex] = item.copy();
    return storedCollection.copy();
  }
  
  deleteItem(collectionId: number, itemId: number): Collection | null {
    const storedCollection = this.collections.find(
      storedCollection => storedCollection.id === collectionId
    );
    
    if (!storedCollection) return null;

    storedCollection.items = storedCollection.items.filter(
      item => item.id !== itemId
    )

    return storedCollection.copy();
  }
  
}

Si on retourne voir le résultat dans la console de notre navigateur, on voit bien que le message Hello World y est affiché :

Création des méthodes CRUD

Maintenant que nous savons comment créer un service et comment l’injecter dans notre composant, il est temps d’implémenter les méthodes de gestion de collections.

Retournons dans notre fichier collection-service.ts, et commençons par effacer la méthode hello et par y créer un tableau de Collection qu’on va appeler « collections ». Ajoutons-y la collection par défaut que nous avons créé dans le constructeur du fichier app.ts, et créons également les méthodes suivantes:

  • getAll()
  • get(collectionId: number)
  • add(collection: Collection)
  • update(collection: Collection)
  • delete(collectionId: number)
  • addItem(collection: Collection, item: CollectionItem)
  • updateItem(collection: Collection, item: CollectionItem)
  • deleteItem(collectionId: number, itemId: number)
collection-service.ts
import { Injectable } from '@angular/core';
import { Collection } from '../models/collection';
import { CollectionItem } from '../models/collection-item';

@Injectable({
  providedIn: 'root'
})
export class CollectionService {

  private collections: Collection[] = [];
  private currentId = 1;
  private currentItemIndex: {[key: number]: number} = {};

  constructor() {
    this.generateDummyData();
  }

  generateDummyData() {
    const coin = new CollectionItem();
    coin.name = 'Pièce de 1972';
    coin.description = 'Pièce de 50 centimes de francs.';
    coin.rarity = 'Commune';
    coin.image = 'img/coin1.png';
    coin.price = 170;
      
    const stamp = new CollectionItem();
    stamp.name = 'Timbre 1800';
    stamp.description = 'Un vieux timbre';
    stamp.rarity = 'Rare';
    stamp.image = 'img/timbre1.png';
    stamp.price = 555;
      
    const linx = new CollectionItem();
      
    const defaultCollection = new Collection();
    defaultCollection.title = "Collection mix";
    const storedCollection = this.add(defaultCollection);
    this.addItem(storedCollection, coin);
    this.addItem(storedCollection, linx);
    this.addItem(storedCollection, stamp);
  }

  getAll(): Collection[] {
    return this.collections.map(collection => collection.copy());
  }

  get(collectionId: number): Collection | null {
    const storedCopy = this.collections.find(
      collection => collection.id === collectionId
    );

    if (!storedCopy) return null;
    return storedCopy.copy();
  }

  add(collection: Omit<Collection, 'id' | 'items'>): Collection {
    
    const storedCopy = collection.copy();
    storedCopy.id = this.currentId;
    this.collections.push(storedCopy);

    this.currentItemIndex[storedCopy.id] = 1;
    this.currentId++;

    return storedCopy.copy();

  }

  update(collection: Omit<Collection, 'items'>): Collection | null {
   const storedCopy = this.collections.find(
    collection => collection.id === collection.id
   );

   if (!storedCopy) return null;
   
   Object.assign(storedCopy, collection);
   return storedCopy.copy();

  }

  delete(collectionId: number): void {
    this.collections = this.collections.filter(
      collection => collection.id !== collectionId
    );
  }

  addItem(collection: Collection, item: CollectionItem): Collection | null {
    const storedCollection = this.collections.find(
      collection => collection.id === collection.id
    );
    
    if (!storedCollection) return null;
    
    const storedItem = item.copy();
    storedItem.id = this.currentItemIndex[collection.id];
    storedCollection.items.push(storedItem);

    this.currentItemIndex[collection.id]++;

    return storedCollection.copy();
  }

  updateItem(collection: Collection, item: CollectionItem) {
    const storedCollection = this.collections.find(
      storedCollection => storedCollection.id === collection.id
    );
    
    if (!storedCollection) return null;

    const storedItemIndex = storedCollection.items.findIndex(
      storedItem => storedItem.id === item.id
    )

    if (storedItemIndex === -1) return null;

    storedCollection.items[storedItemIndex] = item.copy();
    return storedCollection.copy();
  }
  
  deleteItem(collectionId: number, itemId: number): Collection | null {
    const storedCollection = this.collections.find(
      storedCollection => storedCollection.id === collectionId
    );
    
    if (!storedCollection) return null;

    storedCollection.items = storedCollection.items.filter(
      item => item.id === itemId
    )

    return storedCollection.copy();
  }
}

Si on regarde le code attentivement on voir qu’on utilise l’instruction copy() à plusieurs endroits. La raison est la suivante:

  • Nous ne voulons pas stocker l’élément que l’utilisateur nous envoi, car sinon, l’utilisateur de notre service pourrait le modifier sans passer par les méthodes que notre service expose.
  • De plus on retourne une copie de l’élément stocké et non pas l’élément stocké pour la même raison.

Si vous avez du mal à suivre le raisonnement, ne vous focalisez pas sur cette implémentation, car dans les prochaines leçons on modifiera ce service afin qu’il interagisse avec une API.

Maintenant que notre service est implémenté, modifions notre composant app.ts afin qu’il utilise la méthode getAll de notre service afin d’afficher la collection par défaut. N’oublions pas de supprimer l’appel à la méthode hello qui n’existe plus dans notre CollectionService :

app.ts
import { ChangeDetectionStrategy, Component, computed, inject, model, signal } from '@angular/core';
import { CollectionItemCard } from "./components/collection-item-card/collection-item-card";
import { CollectionItem } from './models/collection-item';
import { SearchBar } from "./components/search-bar/search-bar";
import { Collection } from './models/collection';
import { CollectionService } from './services/collection-service';

@Component({
  selector: 'app-root',
  imports: [CollectionItemCard, SearchBar],
  templateUrl: './app.html',
  styleUrl: './app.scss',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {

  collectionService = inject(CollectionService);
  count = 0;
  search = model('');

  collection!: Collection;
  coin!: CollectionItem;
  linx!: CollectionItem;
  stamp!: CollectionItem;

  selectedCollection = signal<Collection | null>(null);
  displayedItems = computed(() => {
    const allItems = this.selectedCollection()?.items || [];
    return allItems.filter(item => 
      item.name.toLowerCase().includes(
        this.search().toLocaleLowerCase()
      )
    );
  });
  
  constructor() {
    const allCollections = this.collectionService.getAll();
    if (allCollections.length > 0) {
      this.selectedCollection.set(allCollections[0]);
    }
  }

}

Si on regarde notre navigateur, on voit bien qu’on a toujours notre trois objets qui sont affichés à l’écran, mais cette fois-ci, la collection ainsi que ses objets sont gérées de manière centralisée par notre service.

Afin de montrer l’utilisation d’une autre méthode du service, je propose d’ajouter un bouton à notre interface qui va ajouter une instance par défaut de CollectionItem à notre collection par défaut. Commençons par créer une méthode addItem dans le fichier app.ts :

app.ts
import { ChangeDetectionStrategy, Component, computed, inject, model, signal } from '@angular/core';
import { CollectionItemCard } from "./components/collection-item-card/collection-item-card";
import { CollectionItem } from './models/collection-item';
import { SearchBar } from "./components/search-bar/search-bar";
import { Collection } from './models/collection';
import { CollectionService } from './services/collection-service';

@Component({
  selector: 'app-root',
  imports: [CollectionItemCard, SearchBar],
  templateUrl: './app.html',
  styleUrl: './app.scss',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {

  collectionService = inject(CollectionService);
  count = 0;
  search = model('');

  collection!: Collection;
  coin!: CollectionItem;
  linx!: CollectionItem;
  stamp!: CollectionItem;

  selectedCollection = signal<Collection | null>(null);
  displayedItems = computed(() => {
    const allItems = this.selectedCollection()?.items || [];
    return allItems.filter(item => 
      item.name.toLowerCase().includes(
        this.search().toLocaleLowerCase()
      )
    );
  });
  
  constructor() {
    const allCollections = this.collectionService.getAll();
    if (allCollections.length > 0) {
      this.selectedCollection.set(allCollections[0]);
    }
  }

  addGenericItem() {
    const genericItem = new CollectionItem();
    const collection = this.selectedCollection();
    
    if (!collection) return;

    const updatedCollection =this.collectionService.addItem(
      collection, genericItem
    );
    this.selectedCollection.set(updatedCollection);
  }

}

Maintenant ajoutons un bouton à app.html qui va executer cette fonction lorsque l’on cliquera dessus :

app.html
<header id="collection-header">
    <h1>{{selectedCollection()?.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"></app-collection-item-card>
                    <hr class="gold">
                </div>
            }
            @case ("Rare") {
                <div>
                    <app-collection-item-card [item]="item"></app-collection-item-card>
                    <hr class="dashed">
                </div>
            }
            @default {
                <app-collection-item-card [item]="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 (click)="addGenericItem()">Ajouter Objet</button>
</div>

Si on retourne dans le navigateur et qu’on clique sur le bouton « Ajouter Objet » on voit bien qu’un nouvel objet est rajouté à notre liste à chaque clique :

LocalStorage

Adaptons notre service afin qu’il stock nos listes de collections dans le localStorage. Pour cela, on va utiliser l’objet localStorage que notre navigateur met à disposition. Ici, 2 méthodes nous intéressent:

  • la méthode « setItem » qui prend un nom de variable en paramètre ainsi que la valeur qu’on souhaite stocker, et
  • la méthode « getItem » qui prend un nom de variable et retourne la valeur stockée sous ce nom.
collection-service.ts
import { Injectable } from '@angular/core';
import { Collection } from '../models/collection';
import { CollectionItem } from '../models/collection-item';

@Injectable({
  providedIn: 'root'
})
export class CollectionService {

  private collections: Collection[] = [];
  private currentId = 1;
  private currentItemIndex: {[key: number]: number} = {};

  constructor() {
    this.load();
  }

  private save() {
    localStorage.setItem('collections', JSON.stringify(this.collections));
  }

  private load() {
    const collectionsJson = localStorage.getItem('collections');
    if (collectionsJson) {
      this.collections = JSON.parse(collectionsJson).map((collectionJson: any) => {
        const collection = Object.assign(new Collection(), collectionJson);
        const itemsJson = collectionJson['items'] || [];
        collection.items = itemsJson.map((item: any) => Object.assign(new CollectionItem, item));
        return collection;
      });
      this.currentId = Math.max(...this.collections.map(collection => collection.id)) + 1;
      this.currentItemIndex = this.collections.reduce(
        (indexes: {[key: number]: number}, collection) => {
          indexes[collection.id] = Math.max(...collection.items.map(item => item.id)) + 1;
          return indexes;
        }, {}
      );
    } else {
      this.generateDummyData();
      this.save();
    }
  }

  generateDummyData() {
    const coin = new CollectionItem();
    coin.name = 'Pièce de 1972';
    coin.description = 'Pièce de 50 centimes de francs.';
    coin.rarity = 'Commune';
    coin.image = 'img/coin1.png';
    coin.price = 170;
      
    const stamp = new CollectionItem();
    stamp.name = 'Timbre 1800';
    stamp.description = 'Un vieux timbre';
    stamp.rarity = 'Rare';
    stamp.image = 'img/timbre1.png';
    stamp.price = 555;
      
    const linx = new CollectionItem();
      
    const defaultCollection = new Collection();
    defaultCollection.title = "Collection mix";
    
    const storedCollection = this.add(defaultCollection);
    this.addItem(storedCollection, coin);
    this.addItem(storedCollection, linx);
    this.addItem(storedCollection, stamp);
  }

  getAll(): Collection[] {
    return this.collections.map(collection => collection.copy());
  }

  get(collectionId: number): Collection | null {
    const storedCopy = this.collections.find(
      collection => collection.id === collectionId
    );

    if (!storedCopy) return null;
    return storedCopy.copy();
  }

  add(collection: Omit<Collection, 'id' | 'items'>): Collection {
    
    const storedCopy = collection.copy();
    storedCopy.id = this.currentId;
    this.collections.push(storedCopy);

    this.currentItemIndex[storedCopy.id] = 1;
    this.currentId++;
    this.save();

    return storedCopy.copy();

  }

  update(collection: Omit<Collection, 'items'>): Collection | null {
    const storedCopy = this.collections.find(
      collection => collection.id === collection.id
    );

    if (!storedCopy) return null;
   
    Object.assign(storedCopy, collection);
    this.save();
    return storedCopy.copy();

  }

  delete(collectionId: number): void {
    this.collections = this.collections.filter(
      collection => collection.id !== collectionId
    );
    this.save();
  }

  addItem(collection: Collection, item: CollectionItem): Collection | null {
    const storedCollection = this.collections.find(
      collection => collection.id === collection.id
    );
    
    if (!storedCollection) return null;
    
    const storedItem = item.copy();
    storedItem.id = this.currentItemIndex[collection.id];
    storedCollection.items.push(storedItem);

    this.currentItemIndex[collection.id]++;
    this.save();

    return storedCollection.copy();
  }

  updateItem(collection: Collection, item: CollectionItem) {
    const storedCollection = this.collections.find(
      storedCollection => storedCollection.id === collection.id
    );
    
    if (!storedCollection) return null;

    const storedItemIndex = storedCollection.items.findIndex(
      storedItem => storedItem.id === item.id
    )

    if (storedItemIndex === -1) return null;

    storedCollection.items[storedItemIndex] = item.copy();
    this.save();

    return storedCollection.copy();
  }
  
  deleteItem(collectionId: number, itemId: number): Collection | null {
    const storedCollection = this.collections.find(
      storedCollection => storedCollection.id === collectionId
    );
    
    if (!storedCollection) return null;

    storedCollection.items = storedCollection.items.filter(
      item => item.id === itemId
    )
    this.save();

    return storedCollection.copy();
  }
}

Donc ici on a créé:

  • la méthode save, qui va stocker nos collections dans le localStorage sous le nom collections
  • la méthode load, qui va charger les collections stockés dans le localStorage. Si aucune valeur n’y est présente, on va faire appel à la méthode generateDummyData, qui va initialiser la tableau de collections avec les objets par défaut, et puis on va les stocker en faisant appel à la méthode save.
  • Ensuite, nous avons aussi rajouté un this.save() à toutes les méthodes qui modifient nos collections

Maintenant si on retourne dans notre navigateur et qu’on ajoute quelques objets génériques à notre collection par défaut, on voit que si on rafraichit la page, notre collection contient toujours les objets ajoutés :

Et voilà, on a fait le tour des services en Angular. Dans le prochain chapitre on parlera de navigation et on verra comment adapter les composants affichés à l’écran dépendant de la route utilisé.

Laisser un commentaire