l) Programmation asynchrone & les promesses

YoutubeImage

Utilisation de promesses & async / await

Introduction

Plutôt que d'utiliser des .then() pour chaîner des traitements asynchrones, il est possible de simplifier la syntaxe des promesses à l'aide de async et await.

On va donc écrire du code d'une manière équivalente à ce qui serait fait en programmation synchrone, tout en bénéficiant des effets de la programmation asynchrone.

async / await : les bases

Pour ce nouveau tutoriel, nous allons refactorer l'IHM pour améliorer le code associé aux appels asynchrones aux API.

Au sein de votre repo web2, veuillez créer le projet nommé /web2/tutorials/pizzeria/hmi/async-await sur base d'un copié/collé de /web2/tutorials/pizzeria/hmi/basic-fetch (ou basic-fetch-hmi).

Pour la suite du tutoriel, nous considérons que tous les chemins absolus démarrent du répertoire /web2/tutorials/pizzeria/hmi/async-await.

Nous allons donc refactorer le code où est fait le fetch, c'est à dire /src/Components/Pages/HomePage.js (veuillez mettre à jour tout le code de la fonction arrow associée à la variable HomePage) :

js
1
const HomePage = async () => {
2
try {
3
clearPage();
4
5
const response = await fetch('/api/pizzas');
6
7
if (!response.ok) throw new Error(`fetch error : ${response.status} : ${response.statusText}`);
8
9
const pizzas = await response.json();
10
11
renderMenuFromString(pizzas);
12
attachOnMouseEventsToGoGreen();
13
renderDrinksFromNodes(DRINKS);
14
} catch (err) {
15
console.error('HomePage::error: ', err);
16
}
17
};

Pour tester ce code, il ne faut pas oublier de démarrer la RESTful API auparavant : /web2/tutorials/pizzeria/api/fat-model ou via le code de ce web repo si vous avez un souci : api-fat-model.

Voici quelques caractéristiques importantes de async / await :

  • await est utilisé pour chaîner une tâche asynchrone (sur une fonction renvoyant une promesse) et ne peut se faire qu'au sein d'une fonction taguée par async ; c'est donc le remplaçant du .then(callback).
    ⚡ Attention, il est donc important qu'au niveau de la fonction arrow, à la ligne 1 du code donné ci-dessus, on indique le async !
    ⚡ Dans le code donné ci-dessus, il est aussi très important de ne pas oublier les await. N'hésitez pas à faire le test en enlevant le await de const pizzas = await response.json();.
    Que se passe-t-il dans ce cas ? response.json() étant une fonction asynchrone, on passera directement à la fonction renderMenuFromString(pizzas); avant même d'avoir récupéré les pizzas de notre RESTful API !
  • Toute fonction "taguée" par async renvoie automatiquement une promesse ; cela signifie dans le code ci-dessus que la fonction HomePage est elle même asynchrone.
    💭 Il est à parier, et n'hésitez pas à trouver un moyen de vous en rendre compte visuellement, que le footer s'affiche avant le menu !
  • On utilise des blocs try / catch pour gérer les erreur ; c'est donc le remplaçant du .catch(callback).

Opération asynchrone d'écriture d'une ressource

A présent, nous souhaiterions que notre IHM puisse créer une ressource au sein de la RESTful API. Dans un premier temps, nous allons mettre à jour le frontend en acceptant que n'importe quel utilisateur puisse créer une pizza et l'ajouter au menu de la pizzeria.
Bien entendu, cela est temporaire. Nous verrons plus tard comment sécuriser cette opération, en autorisant un admin seulement à réaliser l'ajout d'une pizza au menu.

Nous allons créer une nouvelle page nommée AddPizzaPage.js. Pour commencer, nous allons réaliser une page statique car il est toujours plus aisé de d'abord avoir une version visuelle d'une application web, avec la navigation entre les pages, avant de peaufiner chaque page et leurs interactions avec les utilisateurs et les APIS. Veuillez ajouter ce code dans la nouvelle page /src/Components/Pages/AddPizzaPage.js:

js
import { clearPage, renderPageTitle } from '../../utils/render';
const AddPizzaPage = () => {
clearPage();
renderPageTitle('Add a pizza to the menu');
renderAddPizzaForm();
};
function renderAddPizzaForm() {
const main = document.querySelector('main');
const form = document.createElement('form');
form.className = 'p-5';
const title = document.createElement('input');
title.type = 'text';
title.id = 'title';
title.placeholder = 'title of your pizza';
title.required = true;
title.className = 'form-control mb-3';
const content = document.createElement('input');
content.type = 'text';
content.id = 'content';
content.required = true;
content.placeholder = 'Content of your pizza';
content.className = 'form-control mb-3';
const submit = document.createElement('input');
submit.value = 'Add pizza to the menu';
submit.type = 'submit';
submit.className = 'btn btn-danger';
form.appendChild(title);
form.appendChild(content);
form.appendChild(submit);
main.appendChild(form);
}
export default AddPizzaPage;

Nous devons aussi ajouter un élément dans la Navbar afin de pouvoir accéder à cette nouvelle page. Pour ce faire, veuillez mettre à jour /src/Components/Navbar/Navbar.js :

js
1
// eslint-disable-next-line no-unused-vars
2
import { Navbar as BootstrapNavbar } from 'bootstrap';
3
4
const Navbar = () => {
5
renderNavbar();
6
};
7
8
function renderNavbar() {
9
const navbar = document.querySelector('#navbarWrapper');
10
navbar.innerHTML = `
11
<nav class="navbar navbar-expand-lg navbar-light bg-danger">
12
<div class="container-fluid">
13
<a class="navbar-brand" href="#">e-Pizzeria</a>
14
<button
15
class="navbar-toggler"
16
type="button"
17
data-bs-toggle="collapse"
18
data-bs-target="#navbarSupportedContent"
19
aria-controls="navbarSupportedContent"
20
aria-expanded="false"
21
aria-label="Toggle navigation"
22
>
23
<span class="navbar-toggler-icon"></span>
24
</button>
25
<div class="collapse navbar-collapse" id="navbarSupportedContent">
26
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
27
<li class="nav-item">
28
<a class="nav-link active" aria-current="page" href="#" data-uri="/">Home</a>
29
</li>
30
<li id="loginItem" class="nav-item">
31
<a class="nav-link" href="#" data-uri="/login">Login</a>
32
</li>
33
<li id="registerItem" class="nav-item">
34
<a class="nav-link" href="#" data-uri="/register">Register</a>
35
</li>
36
<li class="nav-item">
37
<a class="nav-link" href="#" data-uri="/add-pizza">Add a pizza</a>
38
</li>
39
</ul>
40
</div>
41
</div>
42
</nav>
43
`;
44
}
45
46
export default Navbar;

Et finalement, comme le frontend de ce tutoriel utilise le boilerplate du cours, il faut encore configurer le router de l'IHM afin d'indiquer la page à afficher lorsqu'on clique sur le lien dont data-uri vaut "/add-pizza". Pour ce faire, veuillez mettre à jour le fichier /src/Components/Router/Router.js pour ajouter ces deux lignes :

js
import AddPizzaPage from '../Pages/AddPizzaPage';
import HomePage from '../Pages/HomePage';
import LoginPage from '../Pages/LoginPage';
import RegisterPage from '../Pages/RegisterPage';
const routes = {
'/': HomePage,
'/login': LoginPage,
'/register': RegisterPage,
'/add-pizza': AddPizzaPage,
};

A ce stade-ci, votre application /web2/tutorials/pizzeria/hmi/async-await devrait être fonctionnelle, vous devriez pouvoir naviguer vers la nouvelle page contenant un formulaire pour ajouter une pizza.

Nous allons maintenant ajouter l'interaction avec l'API au sein de AddPizzaPage. Lorsque nous soumettons le formulaire, nous voulons faire une requête de création de pizza à la RESTful API, c'est donc une requête de type POST /pizzas qui doit être l'équivalent de ce que nous faisions avec REST Client. Pour rappel, nous faisions une requête de ce genre :

http
### Create a pizza
POST {{baseUrl}}/pizzas
Content-Type: application/json
{
"title":"Magic Green",
"content":"Epinards, Brocolis, Olives vertes, Basilic"
}

Ici, c'est le JS à rajouter dans AddPizzaPage qui doit, lors du clic, aller chercher les valeurs des deux champs du formulaire pour créer la représentation des données (title et content) et faire un fetch de l'opération de création offerte par l'API.
Si l'ajout se fait avec succès, on souhaite faire en sorte que l'utilisateur soit redirigé vers la HomePage.

Pour arriver à nos fins, veuillez ajouter ce code dans la page /src/Components/Pages/AddPizzaPage.js :

js
1
import { clearPage, renderPageTitle } from '../../utils/render';
2
import Navigate from '../Router/Navigate';
3
4
const AddPizzaPage = () => {
5
clearPage();
6
renderPageTitle('Add a pizza to the menu');
7
renderAddPizzaForm();
8
};
9
10
function renderAddPizzaForm() {
11
const main = document.querySelector('main');
12
const form = document.createElement('form');
13
form.className = 'p-5';
14
const title = document.createElement('input');
15
title.type = 'text';
16
title.id = 'title';
17
title.placeholder = 'title of your pizza';
18
title.required = true;
19
title.className = 'form-control mb-3';
20
const content = document.createElement('input');
21
content.type = 'text';
22
content.id = 'content';
23
content.required = true;
24
content.placeholder = 'Content of your pizza';
25
content.className = 'form-control mb-3';
26
const submit = document.createElement('input');
27
submit.value = 'Add pizza to the menu';
28
submit.type = 'submit';
29
submit.className = 'btn btn-danger';
30
form.appendChild(title);
31
form.appendChild(content);
32
form.appendChild(submit);
33
main.appendChild(form);
34
form.addEventListener('submit', onAddPizza);
35
}
36
37
async function onAddPizza(e) {
38
e.preventDefault();
39
40
const title = document.querySelector('#title').value;
41
const content = document.querySelector('#content').value;
42
43
const options = {
44
method: 'POST',
45
body: JSON.stringify({
46
title,
47
content,
48
}),
49
headers: {
50
'Content-Type': 'application/json',
51
},
52
};
53
54
const response = await fetch('/api/pizzas', options); // fetch return a promise => we wait for the response
55
56
if (!response.ok) throw new Error(`fetch error : ${response.status} : ${response.statusText}`);
57
58
const newPizza = await response.json(); // json() returns a promise => we wait for the data
59
60
console.log('New pizza added : ', newPizza);
61
62
Navigate('/');
63
}
64
65
export default AddPizzaPage;

Quelques explications sur ce code, pour les parties déjà connues :

  • La gestion d'événements n'est pas nouvelle. Ici, on met un écouteur d'événements de type submit sur le formulaire. Cela permet d'écouter tant les clics sur le champs de type submit (le bouton) que si l'utilisateur appuie sur Enter.
    👍 On recommande, pour les formulaires, d'utiliser des événements de type submit plutôt que des événements de type click sur le bouton submit afin notamment de prendre en compte si l'utilisateur appuie sur Enter pour tenter de soumettre le formulaire.
  • L'action par défaut d'un formulaire, lors d'un submit, et de faire une requête synchrone vers l'URL du backend indiqué dans la propriété action du formulaire, ou sur la même URL que la page en cours si action n'est pas donné. Pour éviter un chargement de page non désiré dans le cadre d'une SPA, on stoppe cette action par défaut via e.preventDefault().

Pour la nouveauté et le fetch :

  • pour faire une requête de type POST, tout comme pour les requêtes de type DELETE, PATCH, UPDATE..., il faut l'indiquer à la méthode fetch.
    Cela est indiqué dans un objet que nous appelons généralement options qui doit contenir la propriété method.
  • lorsque l'on doit envoyer des données dans le body d'une requête, alors il faut le faire au sein de la propriété body. Ici, nous souhaitons envoyer un objet contenant les propriétés title et content au format JSON. Nous devons donc utiliser la méthode JSON.stringify qui permet de créer une représentation JSON d'un objet JS. Notez ici que nous avons écrit l'objet JS selon une notation simplifiée ("object property shorthand") :
js
const title = document.querySelector('#title').value;
const content = document.querySelector('#content').value;
{
title,
content,
}
// Cet object literal est l'équivalent de :
{
title: title,
content: content,
}
  • Il est très important de spécifier le type de la représentation de l'objet qui devrait être utilisé par l'API et qui se trouve dans le body de la requête. Cela est fait via un header et la propriété Content-Type ('Content-Type': 'application/json',).
    ⚡ Si vous oubliez cela, l'API ne pourra pas parser les données au format JSON vers des objets JS et donc les opérations d'écriture de ressources échoueront !
  • Finalement, si l'opération de création de la pizza réussi, nous redirigeons l'utilisateur vers la HomePage à l'aide du composant fonctionnel Navigate offert au sein du module Navigate.js dans le dossier Router du boilerplate du frontend.

Si tout fonctionne bien, faites un commit de votre repo (web2) avec comme message : async-await-hmi tutorial.

En cas de souci, vous pouvez accéder au code du tutoriel ici : async-await-hmi.

Création de fonctions asynchrones renvoyant une promesse

A l'aide d'async / await, il est très simple de créer des fonctions asynchrones qui renvoient une promesse.

Imaginons que nous souhaitons créer une fonction asynchrone qui renvoie toutes les pizzas qui sont offertes par l'opération de lecture des pizzas de la RESTful API.

Voici comment nous écririons ce code :

js
async function getAllPizzas() {
try {
const response = await fetch('/api/pizzas');
if (!response.ok) throw new Error(`fetch error : ${response.status} : ${response.statusText}`);
const pizzas = await response.json();
return pizzas;
} catch (err) {
console.error('getAllPizzas::error: ', err);
throw err;
}
}

Cette fonction getAllPizzas ne renvoie pas un array de pizzas, mais une Promise !
Si la promesse :

  • résout avec succès, alors c'est bien un array de pizzas qui sera renvoyé par cette fonction.
  • échoue, c'est une exception qui sera renvoyée.
    Pour que cela fonctionne, vous devez donc faire en sorte, dans vos fonctions asynchrones, de faire un throw d'une erreur en cas d'échec du traitement asynchrone.

Comment utiliser ce code au sein de la HomePage ?
Voici comment le code pourrait être mis à jour pour utiliser la fonction asynchrone getAllPizzas au sein de HomePage.js :

js
const HomePage = async () => {
try {
clearPage();
const pizzas = await getAllPizzas();
renderMenuFromString(pizzas);
attachOnMouseEventsToGoGreen();
renderDrinksFromNodes(DRINKS);
} catch (err) {
console.error('HomePage::error: ', err);
}
};

Exercice 2.19 : Intégration de la RESTful API au sein de l'IHM de myMovies via un proxy

Objectif

Vous allez intégrer le frontend de myMovies avec sa RESTful API afin que toutes les opérations CRUD soient traitées par votre application.

L'application myMovies doit permettre tous ces cas d'utilisation (ou use cases):

  • UC1 : l'affichage, sous forme de tableau, de toutes les ressources de type films.
  • UC2 : l'ajout d'une ressource de type films via un formulaire d'ajout d'un film.
  • UC3 : la suppression d'un film.
  • UC4 : la mise à jour des données d'un film (à l'exception de l'id associé à un film).

Nous acceptons, à ce stade-ci, que des utilisateurs anonymes puissent réaliser des opérations qui normalement devraient être sécurisées. Nous verrons plus tard comment authentifier et autoriser des utilisateurs afin de protéger l'accès aux opérations d'API.

Veuillez utiliser le proxy de votre frontend afin de contourner les problèmes associé à la gestion des CORS. Tous les appels aux opérations des API doivent se faire à l'aide de async / await.

Mise en place des projets

Vous allez développer le frontend de manière incrémentale.

UC1 : l'affichage, sous forme de tableau, de toutes les ressources de type films

Veuillez consommer l'opération de lecture de films de l'API au sein de ViewMoviePage à l'aide de async / await et du proxy.

Quand c'est fonctionnel, veuillez faire un commit de votre code avec comme message : 2.19.1 : spa read operation & async / await.

UC2 : l'ajout d'une ressource de type films via un formulaire d'ajout d'un film

Veuillez consommer l'opération de création de films de l'API au sein de AddMoviePage à l'aide de async / await et du proxy.

⚡ Lors de l'ajout d'un film, n'oubliez pas que le budget et la durée doivent être des nombres, pas des strings !

Quand c'est fonctionnel, veuillez faire un commit de votre code avec comme message : 2.19.2 : spa create operation.

UC3 : la suppression d'un film

Veuillez consommer l'opération de suppression de films de l'API au sein de ViewMoviePage à l'aide de async / await et du proxy.

Quand c'est fonctionnel, veuillez faire un commit de votre code avec comme message : 2.19.3 : spa delete operation.

UC4 : la mise à jour des données d'un film (à l'exception de l'id associé à un film)

Veuillez consommer l'opération de mise à jour de films de l'API au sein de ViewMoviePage à l'aide de async / await et du proxy.

Quand c'est fonctionnel, veuillez faire un commit de votre code avec comme message : 2.19.4 : spa update operation.

🤝 Tips

Comment gérer l'UC de suppression d'un film ?

  • Vous pourriez avoir un bouton Delete pour chaque ligne du tableau affichant les films. Lors du clic sur un bouton Delete, vous feriez une requête de type DELETE vers la RESTful API. Attention, pour une requête de type DELETE, l'identifiant de l'objet à supprimer doit être donnée dans l'URL associée au fetch.
  • Comment retrouver l'identifiant du film affiché dans le tableau ?
    Pensez au data-attribute, vous pouvez cacher de l'information dans l'HTML. Par exemple, chaque bouton Delete pourrait contenir un data-attribute étant l'id du film. Il est aussi possible d'utiliser la propriété id du bouton pour "cacher" de l'info.

Comment gérer la mise à jour des données d'un film ? Nous vous proposons deux options à choix :

  • L'option 1 : elle va vous permettre de ne pas devoir créer des formulaires et de directement mettre à jour de l'info en modifiant le contenu de containers HTML.
  • L'option 2 : vous pourriez créer des formulaires en les pré-remplissant des données existantes.

Option 1 pour la mise à jour d'info : la plus cool à découvrir 😉

Vous pourriez faire en sorte que l'HTML du tableau affichant les films, lors d'un clic, devienne éditable. Pour cette option :

  • Voici, à quoi pourrait ressembler votre application à la fin de l'exercice. Vous avez bien sûr la liberté de faire quelque chose de totalement différent visuellement !
GatsbyImage
  • Veuillez noter que, contrairement à ce qui a été fait pour l'Exercice 2.11, le title et le link ne sont plus intégrés dans une même colonne, via des hyperlinks ; avec l'option 1, nous devrions considérer deux colonnes, une pour le titre et l'autre pour le lien.
  • Vous pouvez utiliser la propriété HTML contenteditable="true" pour rendre les cellules du tableau éditables. Voici un exemple pour rendre une cellule associée au titre éditable :
    js
    <td class="fw-bold text-info" contenteditable="true">${
    element.title
    }</td>
  • Pour accéder aux cellules qui se trouvent dans une même ligne que vous mettez à jour, vous pouvez utiliser la DOM API de votre browser :
    • on obtient le parent d'un élément HTML via l'attribut parentElement ; par exemple, si vous avez un écouteur d'événements de clics sur un bouton Save, ce bouton se trouvant au sein d'une td qui se trouve elle-même au sein d'une tr : e.target.parentElement.parentElement donne accès à la tr associée au bouton Save sur lequel on a cliqué.
    • on accède aux enfants d'un élément HTML via l'attribut children ; par exemple, tr.children[0] donne accès à la première td au sein de tr.
  • Vous pourriez avoir un bouton Save pour chaque ligne du tableau affichant un film. Lors d'un clic sur un bouton Save, vous faites appel à l'API en faisant la requête de mettre à jour toutes les propriétés du film, même celle n'ayant pas de nouvelles valeurs.
  • Attention, pour une requête de type PUT (mise à jour de toutes les propriétés d'une ressource) ou de type PATCH (mise à jour partielle d'une ressource), vous devez indiquer l'id dans l'URL du fetch, et la représentation de données à mettre à jour doit se trouver dans le body de la requête (ce sont les conventions REST que nous avons fixées dans le cadre de ce cours).
  • Si vous aviez besoin de réaliser une action en cas de changement du contenu d'une cellule dont contenteditable est activé, vous pouvez gérer le type d'événement input.

Option 2 pour la mise à jour d'info

Il est aussi possible de créer un nouveau composant Javascript (une page, une modal ou autre) qui reprendrait un formulaire dont les inputs contiendraient déjà les valeurs existantes des propriétés d'un film. Si vous choisissez cette option, c'est à vous de trouver l'inspiration 😉.