Un système d'autocomplétion avec Angular

Afficher des suggestions dynamiquement pour vos utilisateurs

Simon Dieny

Cet article vous permettra d'apprendre à mettre en place un système d'auto-complétion au sein de votre application Angular, via la bibliothèque RxJS. Commentez

- Titre : Un système d'autocomplétion avec Angular

- Auteur : Simon Dieny

- Parution : 23 octobre 2017

- Mise à jour : 26 octobre 2017

- Licence : Copyright ® 2017 Simon Dieny. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Préambule

- I-A. Ce que vous allez réaliser

- I-A-1. Préambule

- I-A-2. Spécifications

- I-A-3. Prérequis

- II. Mise en place

- II-A. Le socle initial

- II-B. Simuler une API

- II-B-1. Mettre au point une API « mock »

- II-B-2. Déclarer son API au reste de l'application

- II-C. Créer un service pour l'autocomplétion

- II-D. Créer un composant pour l'autocomplétion

- II-D-1. Termes de recherche

- II-D-2. Initialiser un observable

- II-D-3. Importer les opérateurs RxJS

- II-E. Intégrer notre composant dans le reste de l'application

- III. Conclusion

- III-A. En résumé

- III-B. Pour aller plus loin

- III-C. Remerciements

I - Préambule

I-A - Ce que vous allez réaliser

I-A-1 - Préambule

Dans ce tutoriel, je vous propose de réaliser un système d'autocomplétion simple avec Angular. Pour rappel, voici ce qu'est un système d'autocomplétion :

Figure 1 - Exemple d'un système d'autocomplétion.

Lorsque l'utilisateur renseigne les termes de sa recherche, alors une liste de suggestions correspondantes lui est proposée. Dans ce tutoriel nous allons mettre en place un champ de recherche de Pokémons, qui est un exemple qui parle à tout le monde.

I-A-2 - Spécifications

Le système devra se déclencher dès que l'utilisateur saisit au moins un caractère dans le champ de recherche. Les suggestions affichées seront celles qui contiennent au moins une fois le terme de la recherche.

Contrairement à un autre système d'autocomplétion qui prend en compte seulement le préfixe de la recherche pour générer les suggestions, nous afficherons des suggestions dès qu'elles contiennent au moins une fois le terme recherché.

I-A-3 - Prérequis

Avant de commencer à suivre ce tutoriel, outre le fait de connaître un minimum Angular, une connaissance préalable de la programmation réactive peut être un plus. Si vous savez déjà ce qu'est un Observable, ce serait mieux, mais vous pouvez quand même essayer de suivre le tutoriel.

Les exemples de code ci-dessous sont en TypeScript, donc ce tutoriel nécessite un minimum de connaissance sur ce sujet également.

II - Mise en place

II-A - Le socle initial

Pour commencer, nous devons mettre en place un socle initial d'application Angular. Je vous recommande Angular-CLI si vous connaissez déjà, sinon vous pouvez partir d'un dossier vide, en suivant mon précédent tutoriel (dans ce cas, vous pouvez partir de la branche « hello, world » de ce dépôt Github).

Ce précédent tutoriel vous présente comment réaliser un « hello, world » avec Angular, en partant d'un dossier vide sur votre machine locale.

Avant de commencer, voici les étapes à suivre pour construire notre système d'autocomplétion :

- construire le socle de base dont je viens de vous parler. Mettez-le en place sur votre machine locale avant de continuer ;

- ajouter un système permettant de simuler une API en renvoyant une liste de Pokémons ;

- créer le composant d'autocomplétion qui interrogera notre API ;

- intégrer ce composant dans le socle initial.

Dans ce tutoriel, j'utilise la bibliothèque Materialize pour le style de notre application. Concrètement, il vous suffit d'ajouter la ligne suivante dans le fichier index.html pour installer cette bibliothèque :

1. <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.100.2/css/materialize.min.css">

II-B - Simuler une API

II-B-1 - Mettre au point une API « mock »

La première chose à faire, c'est de simuler une API qui renvoie une liste de Pokémons.

Angular propose un système plutôt sympathique pour démarrer vos projets avec un jeu de données initiales, distribué sous forme d'API au reste de votre application. Cela permet de simuler la communication avec un serveur distant même si vous n'en avez pas vraiment un.

Cette simulation est possible grâce au module InMemoryWebApiModule de la bibliothèque angular-in-memory-web-api. Regardons tout de suite comment cela fonctionne.

Créer un fichier in-memory-data.service.ts, à côté de votre app.component.ts (nous allons essayer de garder un projet de petite taille, pour éviter toute confusion par la suite).

Voici le code de ce nouveau fichier :

In-memory-data.service.ts

1. Import {InMemoryDbService} from 'angular-in-memory-web-api';

2. Import {Pokemon} from 'mock-pokemon';

4. Export class InMemoryDataService implements InMemoryDbService {

5. createdDB() {

6. let pokemons = POKEMONS;

7. return {pokemons};

8. }

9. }

Contrairement aux apparences, ce service fait plus que de simplement renvoyer une liste de Pokémons.

La classe InMemoryDataService implémente l'interface InMemoryDataService, qui nécessite d'implémenter la méthode createDb. Cette méthode permet de simuler une petite base de données et une API pour notre application.

Cette API met en place plusieurs points de terminaisons sur lesquels on peut effectuer des requêtes :

- GET api/pokemons // renvoie tous les pokémons

- GET api/pokemons/1 // renvoie le pokémon avec l'identifiant 1

- PUT api/pokemons/1 // modifie le pokémon avec l'identifiant 1

- GET api/heroes?name=^j // retourne les pokémons dont la propriété 'name' commence par 'j'

Bien pratique, n'est-ce pas ? Il est possible de faire toutes les requêtes imaginables pour une petite application de démonstration : ajout, suppression, recherche, modification, etc.

Merci Angular !

Cependant, il y a un fichier que je ne vous ai pas encore donné, c'est la liste des Pokémons eux-mêmes.

Comme il s'agit d'un fichier très long qui exporte simplement un JSON, voici le lien où vous pourrez récupérer la liste des Pokémons. Créez donc un fichier mock-pokemon.ts et copiez-collez le contenu à l'intérieur.

Vous aurez aussi besoin de la classe qui modélise un Pokémon au sein de notre application. Le lien se trouve ici. De la même manière, créer un fichier pokemon.ts et copier-coller le contenu à l'intérieur.

II-B-2 - Déclarer son API au reste de l'application

Il ne nous reste plus qu'à déclarer notre API simulée auprès du reste de l'application. Pour cela, il est recommandé d'enregistrer les services globaux à toute l'application dans la liste imports du module racine :

App.module.ts

1. // …

2. // Importations pour charger et configurer l'API simulée.

3. import { InMemoryWebApiModule } from 'angular-in-memory-web-api';

4. import { InMemoryDataService } from './in-memory-data.service';

6. // …

7. @NgModule({

8. imports: [

9. // …

10. HttpModule,

11. InMemoryWebApiModule.forRoot(InMemoryDataService),

12. ],

13. // …

La méthode de configuration forRoot prend en paramètre la classe InMemoryDataService, qui apprête la base de données en mémoire avec les données du service.

Voilà, notre API est prête à être utilisée, au boulot !

Même s'il s'agit d'une API simulée, nous allons interagir avec elle comme avec n'importe quel service distant, c'est exactement le même fonctionnement pour nous, côté Angular !

II-C - Créer un service pour l'autocomplétion

Nous allons créer un service permettant de retourner des suggestions de Pokémons en fonction d'un mot-clef donné. Créez donc un fichier pokemon-search.service.ts :

Pokemon-search.service.ts

1. import { Injectable } from '@angular/core';

2. import { Http, Response } from '@angular/http';

3. import { Observable } from 'rxjs';

4. import { Pokemon } from './pokemon';

6. @Injectable()

7. export class PokemonSearchService {

9. constructor(private http: Http) {}

10. search(term: string): Observable {

11. return this.http

12. .get(`app/pokemons/?name=${term}`)

13. .map((r: Response) => r.json().data as Pokemon[]);

14. }

15. }

La fonction search à la ligne 10 retourne un observable, comme l'indique la signature de la fonction.

Dans cette fonction j'utilise une URL spéciale qui permet de filtrer les Pokémons d'après leur propriété name. Ceci est possible grâce au in-memory-data-service d'Angular, comme nous l'avons énoncé précédemment.

Ensuite on applique la méthode map sur la liste des Pokémons trouvés, afin de les retourner sous forme d'un tableau d'objets de type pokemon.

II-D - Créer un composant pour l'autocomplétion

Comment utiliser l'Observable renvoyé par notre service depuis un composant métier ? Pour illustrer le fonctionnement, nous allons créer un nouveau composant PokemonSearchComponent. Ce composant aura pour rôle d'afficher et de gérer les interactions de l'utilisateur avec le champ de recherche des Pokémons, et d'afficher une liste de suggestions correspondant à sa requête.

Le template de ce composant sera élémentaire : un simple champ de recherche et une liste de résultats correspondants aux termes de la recherche :

Pokemon-search.component.html

1. <div class='row'>

2. <div class= 'col s12 m6 offset-m3'>

3. <div class='card'>

4. <div class='card-content'>

5. <div class='input-field'>

6. <input #searchbBox (keyup)='search(searchBox.value)' placeholder='Rechercher un Pokémon…'>

7. </div>

9. <ul class='collection'>

10. <li *ngFor='let pokemon in pokemons | async' class='collection-item'>

11. {{ pokemon.name }}

12. </li>

13. </ul>

14. </div>

15. </div>

16. </div>

17. </div>

Hormis les balises nécessaires pour les feuilles de style de Materialize, les lignes de code qui nous intéressent sont :

- la ligne 10 : la directive ngFor affiche une liste de Pokémons, issue de la propriété pokemons du composant associé. Mais la propriété pokemons est un Observable, et non plus un simple tableau ! La directive ngFor ne peut rien faire avec un Observable à moins que nous appliquions dessus le pipe async. Le pipe async appliqué à l'Observable permet de n'afficher le résultat que quand l'Observable a retourné une réponse, et s'occupe de synchroniser la propriété du composant avec la vue.

Le pipe async signifie « asynchrone », et peut également s'appliquer sur les Promesses !

Et maintenant, passons à la classe du composant. Créez le fichier pokemon-search.component.ts dans votre projet, et n'oubliez pas d'y incorporer le template ci-dessus :

Pokemon-search.component.ts

1. import { Component, OnInit } from '@angular/core';

2. import { Router } from '@angular/router';

3. import { Observable } from 'rxjs/Observable';

4. import { Subject } from 'rxjs/Subject';

5. import { PokemonSearchService } from './pokemon-search.service'; // Notre service d' autocomplétion

6. import { Pokemon } from './pokemon'; // Notre modèle de Pokémon

8. @Component({

9. Selector : 'pokemon-search',

10. Template : './pokemon-search.component.html',

11. Providers : [PokemonSearchService]

12. })

13. export class PokemonSearchComponent implements OnInit {

14. private searchTerms = new Subject();

15. pokemons: Observable;

17. constructor(private pokemonSearchService: PokemonSearchService)

19. // Ajoute un terme de recherche dans le flux de l'Observable 'searchTerms'

20. search(term: string): void {

21. this.searchTerms.next(term);

22. }

24. ngOnInit(): void {

25. this.pokemons = this.searchTerms

26. // attendre 300 ms de pause entre chaque requête

27. .debounceTime(300)

28. // ignorer la recherche en cours si c'est la même que la précédente

29. .distinctUntilChanged()

30. .switchMap(term => term

31. // On retourne la réponse s'il y a des résultats…

32. ? this.pokemonSearchService.search(term)

33. // ... ou un observable vide sinon !

34. : Observable.of([]))

35. .catch(error => {

36. console.log(error);

37. // en cas d'erreur, on retourne un résultat vide

38. return Observable.of([]);

39. });

40. }

41. }

Voici les explications de ce code.

II-D-1 - Termes de recherche

Commençons par mettre l'accent sur la propriété searchTerms du début.

La classe subject n'appartient pas à Angular, mais à la bibliothèque RxJS. C'est une classe particulière qui va nous permettre de stocker les recherches successives de l'utilisateur dans un tableau de chaîne de caractères, sous la forme d'un Observable, c'est-à-dire avec une notion de décalage dans le temps. Nous allons voir en quoi cela va nous être utile. Retenez que la classe Subject hérite d'Observable, et donc searchTerms est bien un Observable.

C'est la propriété searchTerms qui permet de stocker les recherches successives. Chaque appel à la méthode Search ajoute une nouvelle chaîne de caractères à SearchTerms. On utilise ensuite la fonction next, car SearchTerms n'est pas un tableau (auquel cas, on aurait pu utiliser la fonction push réservée au simple tableau, tout simplement).

II-D-2 - Initialiser un observable

Un Subject est un Observable : c'est ce qui va nous permettre de transformer le flux de terme de recherche en un flux de tableau de Pokémons, et d'affecter le résultat à la propriété pokemons ! C'est la méthode ngOnInit qui s'occupe de ça.

Le code de cette méthode peut paraitre compliqué, mais rassurez-vous, il y a une explication. En fait, la problématique est la suivante : si nous passons chaque nouvelle saisie de l'utilisateur au service PokemonSearchService, nous allons déclencher une tempête de requête http ! Et ce n'est pas une bonne idée, nous ne voulons pas surcharger inutilement notre API.

Heureusement nous pouvons réduire ce flux de requêtes grâce à un certain nombre d'opérateurs. Nous faisons moins d'appels au PokemonsSearchService, tout en obtenant toujours des résultats aussi rapides. Voici comment :

Et pourquoi ne pas passer par des Promesses ? Je trouve ça plus simple quand même…

Vous avez raison, au sens où, l'utilisation des Promesses est plus simple pour des cas d'utilisation peu poussés. La principale raison d'utiliser RxJS dans notre cas, c'est de s'assurer de l'ordre de retour des requêtes. En effet, RxJS restitue nativement l'ordre des appels, contrairement aux Promesses avec lesquelles il faut gérer à la main l'asynchronisme !

Le lien suivant explique très bien cette différence fondamentale entre les deux. Attention, les explications sont dans la langue de Shakespeare.

II-D-3 - Importer les opérateurs RxJS

Avant de pouvoir profiter de notre nouvelle fonctionnalité de recherche, nous devons encore importer les opérateurs RxJS nécessaires. Les opérateurs RxJS ne sont pas tous disponibles de base dans Angular, et nous devons importer nous-même ceux qui manquent. Pour cela, il faut ajouter les déclarations correspondantes au sommet du ficher pokemon-search.component.ts.

Cependant, même si cette façon de faire est tout à fait acceptable, cela pollue le début de notre fichier avec une longue liste d'importations. Nous adopterons une approche différente dans cet exemple : nous allons combiner toutes les extensions d'Observables que notre application nécessite dans un fichier unique d'importation. Ce fichier se nommera rxjs-extensions.ts, à placer avec les autres fichiers sources de votre projet :

Rxjs-extensions.ts

1. // Les extensions de la classe Observable

2. import 'rxjs/add/observable/of';

3. import 'rxjs/add/observable/throw';

5. // Les opérateurs d'Observable

6. import 'rxjs/add/operator/catch';

7. import 'rxjs/add/operator/debounceTime';

8. import 'rxjs/add/operator/distinctUntilChanged';

9. import 'rxjs/add/operator/do';

10. import 'rxjs/add/operator/filter';

11. import 'rxjs/add/operator/map';

12. import 'rxjs/add/operator/switchMap';

Ensuite, nous chargeons le tout en important rxjs-extensions.ts au sommet de votre module racine, normalement contenu dans le fichier app.module.ts. Maintenant les opérateurs sont disponibles dans toute l'application !

import './rxjs-extensions';

C'est plus propre comme ça non ? Enfin, vous faites comme vous voulez …

II-E - Intégrer notre composant dans le reste de l'application

Il ne nous reste plus qu'à incorporer notre composant d'autocomplétion dans le socle initial. Pour cela, rien de très compliqué, il suffit d'utiliser la balise du composant : <pokemon-search></pokemon-search> !

Modifiez donc le template du composant racine app.component.ts, en ajoutant notre balise !

App.component.html

1. <h1>Hello, world !</h1>

2. <pokemon-search></pokemon-search>

Enfin, on importe PokemonSearchComponent dans le module racine, et on l'ajoute au tableau declarations du module racine :

App.module.ts

1. // ajoutez cette importation…

2. Import {PokemonSearchComponent} from './pokemon-search.component';

3. // …

4. Declaration: [

5. //…

6. PokemonSearchComponent // <- n'oubliez pas d'ajoutez cela !

7. ],

8. …

Lancez l'application à nouveau avec la commande npm start, vous verrez notre champ d'autocomplétion ! Entrez du texte dans le nouveau champ de recherche qui est apparu. Voici ce que vous devriez obtenir :

Figure 2 - Le résultat de votre travail, comme promis !

Pas mal, non ? Vous savez désormais implémenter un champ d'autocomplétion pour vos futures applications Angular !

Si vous avez utilisé Angular-CLI, lancez la commande ng serve -open plutôt que npm start.

III - Conclusion

Nous avons pu voir comment simuler une API, et comment utiliser intelligemment les Observables. Vous pouvez commencer à développer vos requêtes avant même d'avoir un serveur distant à votre disposition, et ça c'est très pratique quand on commence un nouveau projet et que le backend n'est pas forcément encore prêt.

III-A - En résumé

- Il est possible de mettre en place une API web de démonstration au sein de vos applications. Cela vous permettra d'interagir avec un jeu de données initial.

- Les Observables sont une façon de gérer les événements asynchrones.

- Les Observables sont particulièrement bien adaptés pour gérer des séquences d'événements.

- Les opérateurs RxJS ne sont pas tous disponibles dans Angular. Il faut étendre leur implémentation en important vous-même les opérateurs nécessaires.

III-B - Pour aller plus loin

Si ce tutoriel vous a plu et que vous souhaitez alleenr plus loin, je vous invite à me rendre visite sur mon blog personnel. Vous pouvez également retrouver mes autres tutoriels sur Developpez.com ici. Bonne continuation à tous et à toutes !

III-C - Remerciements

Je remercie sincèrement Malick et Vermine pour leur suivi régulier, Marco46 pour la relecture technique, et ClaudeLELOUP pour la relecture orthographique.