TP4 - Image Gallery - Partie 1
#
IntroductionVous connaissez désormais les principes basiques de React et êtes capables de développer des applications par vous même. L'objectif de ce TP est de vous faire aller un peu plus loin : découvrir quelques fonctionnalités avancées de React, telles que useReducer
et l'API Context
, afin de développer une petite application réaliste et interagir avec un vrai programme serveur, puis dans un second temps, voir comment tester notre code React à l'aide de bibliothèques de la communauté : Jest et React Testing Library.
#
Objet du TPLors de ces TP, nous allons construire une petite application de galerie photo, permettant aux utilisateurs authentifiés de publier leurs photos.
#
Mise en place#
Starter codePour commencer, nous allons avoir besoin du code de départ, que vous pourrez trouver sur la page suivante.
git clone https://gitlab.com/lp-miaw-react/image-gallery-starter.git
#
Projet GitlabCréez maintenant un projet react-tp4
sur le Gitlab de l'université.
danger
Pensez bien à décocher l'option proposant d'initialiser le repository !
Copiez l'adresse de ce nouveau repository à l'aide du bouton "Cloner avec SSH", puis exécutez les commandes suivantes dans le dossier de votre application clonée à l'étape précédente en remplaçant <git-repo>
par cette dernière :
git remote set-url origin <git-repo>git push -u origin main
Vous devriez maintenant voir sur le README.md
de votre projet sur le Gitlab de l'université la documentation générée par Create React App.
Vous pouvez maintenant lancer le serveur de développement :
npm run start
#
Matériel fourni#
Les composantsComme vous pouvez le constater, les composants du projet existant sont répartis dans deux dossiers différents : components
et pages
. Pour ce TP, nous allons organiser nos composants de la manière suivante : les composants visuels réutilisables seront placés dans le dossier components
, et les composants représentant la partie principale d'une page dans le dossier pages
.
Ces composants visuels sont construits avec l'aide d'une bibiothèque CSS nommée TailwindCSS dont vous avez peut-être entendu parler. Si vous souhaitez modifier le style vous pourrez vous référer à la documentation.
Inspectez les composants existant, pour repérer les interdépendances et leurs fonctionnalités.
#
Le serveurPour ce TP, un programme serveur a été codé et intégré dans votre projet de départ (dans le dossier src/server
). Vous n'avez pas spécialement besoin d'aller en voir le code, sauf si le sujet vous intéresse.
Il se lancera en même temps que votre serveur de développement quand vous lancerez la commande npm run start
.
Il dispose d'un système d'authentification ainsi que d'une API permettant d'ajouter, lire, supprimer ou modifier les éléments qu'il stocke en JSON dans sa base de données.
Ladite base de données est le fichier JSON db.json
situé à la racine de votre projet. N'hésitez pas à le modifier si vous avez fait des bêtises ou souhaitez y changer quelque chose, comme y ajouter un utilisateur par exemple.
Vous pourrez accéder à tous les points de terminaison sur le même hôte que le serveur de développement (en utilisant des URL relatives dans la fonction fetch
par exemple).
#
Préparation#
Le routageA l'aide de vos connaissances, installez React Router dans votre projet et faites en sorte de créer un routeur et les routes correspondant aux différentes pages
:
/
: pageHome
/gallery
: pageGallery
/login
: pageLogin
/logout
: pageLogout
/upload
: pageUpload
Modifiez ensuite le composant NavBar
de manière à utiliser la composant Link
ainsi que les URL adaptées.
#
Les composants visuelsModifiez chacun des composants visuels suivants de manière à ce qu'il puisse être utilisé de la façon spécifiée.
#
NavbarLa barre de navigation est conçue de manière à être responsive, mais les éléments de menus cachés sur un périphérique mobile ne s'affichent pas quand on clique sur le bouton.
Ajoutez un state à ce composant afin qu'il se souvienne de son état (ouvert ou fermé), et plie ou déplie le menu en conséquence.
Transformez ensuite les éléments de menus en éléments Link
vers les pages appropriées. Quitte à ajouter des liens, ajoutez-en un dans le composant Gallery
autour du bouton d'ajout Fab
vers la page /upload
.
#
AlertCe composant affiche toujours le même texte. Modifiez le de manière à ce qu'il puisse être utilisé de la manière suivante :
<Alert status="error" description="Exemple" />
#
ThumbnailModifiez le composant Gallery
de la sorte :
{/* ... */}<Thumbnail url="https://picsum.photos/id/1000/5626/3635" description="Sample from Unsplash"/>{/* ... */}
Faites maintenant en sorte que le composant Thumbnail
utilise ces nouvelles props
.
#
LoginFormModifiez le composant Login
de la façon suivante :
// ...<LoginForm onSubmit={credentials => console.log(credentials)} disabled={false}/>
Modifiez ensuite le composant LoginForm
de manière à ce que les deux inputs
du formulaire soient contrôlés, et que la soumission du formulaire entraîne l'appel de sa prop onSubmit
avec un objet de la forme suivante :
{ username: '', password: ''}
Vous devriez voir apparaître l'objet ci dessus contenant les informations que vous avez tapées dans le console après avoir soumis le formulaire sur la page Login
. La page ne doit pas se rafraîchir.
Faites ensuite en sorte que les champs et le bouton soient désactivés si la prop disabled
est vraie.
Une fois ceci terminé, faites un commit avec le commentaire "Ex 1 : Components preparation" et poussez vers le serveur.
git add .git commit -m 'Ex 1 : Components preparation'git push
#
La galeriePour commencer les choses sérieuses, nous allons travailler sur l'affichage de la galerie de photos.
#
Afficher une liste d'imagesLe composant Gallery
, dans son état actuel, n'affiche qu'une seule photo...
Voici un exemple de données :
const images = [{ id: "1000", author: "0", url: "https://picsum.photos/id/1000/5626/3635", description: "Sample from Unsplash"},{ id: "1001", author: "0", url: "https://picsum.photos/id/1001/5616/3744", description: "Sample from Unsplash"},{ id: "1002", author: "0", url: "https://picsum.photos/id/1002/4312/2868", description: "Sample from Unsplash"}]
Ajoutez ce morceau de code en haut du fichier Gallery.jsx
, puis modifiez ce composant de manière à ce qu'il affiche les photos correspondantes.
#
Utiliser les données du serveurMaintenant que notre composant Gallery
sait afficher une liste d'image, allons chercher la liste des images sur notre serveur.
Le programme serveur expose à cet effet un point de terminaison GET /api/images
, qui renvoie tout simplement une liste d'objets similaires à ceux de la liste fournie précédemment.
Utilisez vos connaissances pour effectuer une requête AJAX avec fetch
afin d'aller chercher cette liste au lieu d'utiliser la variable précédente.
Une fois ceci terminé, faites un commit avec le commentaire "Ex 2 : Remote images read" et poussez vers le serveur.
git add .git commit -m 'Ex 2 : Remote images read'git push
#
L'authentificationNotre programme serveur expose dans son API tout le nécessaire pour réaliser un mécanisme complet d'authentification.
Essayons de modifier le composant Login
, de faire un appel à notre API quand l'utilisateur soumet le formulaire LoginForm
: il nous faut faire un appel avec fetch
à la manière de ce que nous avons fait précédemment.
#
L'API de loginL'API fournie dans le code permet de gérer un mécanisme de connexion. Voici la description de la requête de connexion :
Le point de terminaison POST /api/auth/login
attends un corps en JSON de la forme suivante :
{ "username": "...", "password": "..."}
Le serveur répondra avec le JSON suivant en cas de succès :
{ "msg": "success", "user": { "id": "0", "username": "..." }}
Et avec un code d'erreur HTTP 401 en cas d'échec.
À l'aide de vos connaissances, modifiez le composant Login
de manière à faire l'appel AJAX avec fetch
vers le serveur correspondant à cette description.
tip
info
Il existe par défaut un seul utilisateur, qui utilise l'adresse test@mail.com
et le mot de passe azerty
, mais vous pouvez en créer un nouveau en modifiant le fichier db.json
si vous le souhaitez
Essayez ensuite de stocker un état pour cet appel dans un state, et utilisez ce dernier pour afficher l'échec ou le succès de l'opération à l'aide du composant Alert
.
Une fois ceci terminé, faites un commit avec le commentaire "Ex 3.1 : Authentication request" et poussez vers le serveur.
git add .git commit -m 'Ex 3.1 : Authentication request'git push
#
ReducersUne fonctionnalité utilisant une API asynchrone comme fetch
doit gérer des états nombreux : un état initial, un état de chargement, un état d'erreur, un état de succès etc, ainsi que toutes les actions correspondantes.
Pour simplifier la réflexion autour de la modification de ces états, on peut utiliser la technique nommée reducer : un reducer est une fonction pure qui prend pour paramètre un état initial et une action à effectuer. La fonction s'occupe de retourner un nouvel état ayant pris en compte les modifications.
Cette technique a de nombreux avantages : le résultat est prédictible, reproduisible, et permet de déléguer la logique de gestion d'état dans une seule fonction.
C'est d'ailleurs sur ce principe qu'est basé la bibliothèque Redux, dont vous avez certainement entendu parler.
Voici un exemple de reducer :
const initialState = { count: 0}const counterReducer = (state=initialState, action) => { switch(action){ case 'increment': return { count: state.count+1 } case 'decrement': return { count: state.count-1 } case 'reset': return { count: initialState.count } default: return state }}
Comme vous pouvez le constater, cette fonction retournera un nouvel état en fonction de l'action qui lui est donnée.
useReducer
#
Comment intégrer cette technique de gestion d'état dans un composant React ? On pourrait utiliser useState
, et modifier le state en appelant cette fonction, mais ce ne serait pas très pratique.
Les développeurs de React ont bien entendu prévu ce cas de figure, et ont créé le hook useReducer
. Il prend en paramètre un reducer, et retourne un Array composé de deux éléments : un state
, et une fonction dispatch
. Cette dernière permet d'envoyer une action à notre reducer, qui modifiera le state
et mettra à jour notre composant.
Reprenons le reducer précédent dans un exemple utilisant useReducer
:
SyntaxError: Unexpected token (1:8) 1 : return () ^
Vous êtes libres de passer ce que vous voulez à la fonction dispatch, mais généralement (contrairement à l'exemple qui utilise une chaîne de caractères), on utilise la structure suivante :
dispatch({ type: 'increment', payload: 2 // On peut omettre le payload si l'action n'a pas de données})
Puis dans le reducer:
// ...switch(action.type){ case 'increment': return { count: state.count + action.payload } // ...}// ...
À l'aide de ces informations, modifiez votre composant Login
de façon à en gérer l'état avec useReducer
. Le state devra présenter la forme suivante (dans le cas d'un utilisateur authentifié) :
{ loading: false, error: false, user: { username: 'test@mail.com' }}
Votre reducer devra utiliser les actions suivantes :
login_start
: quand l'utilisateur soumet le formulairelogin_success
: quand l'authentification a réussilogin_error
: quand l'authentification a échoué
Une fois ceci terminé, faites un commit avec le commentaire "Ex 3.2 : Reducing login state" et poussez vers le serveur.
git add .git commit -m 'Ex 3.2 : Reducing login state'git push
#
État globalMaintenant que nous savons authentifier notre utilisateur, nous allons devoir utiliser cette information dans l'ensemble de notre application : la Navbar
, la page Upload
...
state
#
Remonter le Pour pouvoir utiliser cet état dans tous ces composants, il nous faut le placer dans le composant le plus haut : App
. Déplacez-y notre useReducer
.
Il faudrait ensuite faire descendre dans les props des informations, et des fonctions pour déclencher des actions. Cependant, la hiérarchie des éléments est assez grande et il faudrait dans certains cas faire descendre ces props sur de nombreux éléments...
Il existe une autre solution : l'API Context, que nous allons utiliser dans l'exercice suivant.
Afin de préparer cet exercice, créez une fonction login
qui fait la requête AJAX et les dispatch
nécessaires, que nous pourrons faire descendre jusqu'au composant concerné.
#
Créer un contexteVous vous êtes peut-être posé la question : pourquoi doit-on absolument avoir quand on utilise React Router un élément BrowserRouter
plus haut dans la hiérarchie des éléments que les éléments Route
? En fait, ce composant transmet des informations aux composants enfants qui souhaitent les avoir, sans passer par les props, à l'aide de la fonctionnalité Context de React.
Pour commencer à utiliser un contexte, il nous faut en créer un. Pour ce faire, React exporte une fonction nommée createContext
.
Utilisez cette fonction pour créer un contexte nommé AuthContext
en haut de votre fichier App.jsx
.
Ce contexte dispose d'un composant intégré nommé Provider
. C'est ce dernier qui exposera nos informations à tous ses éléments enfants (comme BrowserRouter
).
Ce composant attend une prop value
, qui sera l'information qui sera passée aux éléments enfants.
Exemple :
import React, { createContext } from 'reactconst MyContext = createContext()
const MyApp = () => { return <MyContext.Provider value={{example: true}}> {/* ... */} </MyContext.Provider>}
Créez dans votre composant App
un nouveau contexte nommé AuthContext
, et insérez son Provider
au plus haut de votre application.
Passez à sa prop value
un Array contenant en premier le state, et en deuxième un objet actions
qui contiendra la fonction que nous avons préparée précédemment (login
).
#
Redirections et affichage conditionnelAinsi, n'importe quel élément enfant de notre Provider
pourra utiliser ces informations de la sorte :
const MyComponent = props => { const [state, actions] = useContext(AuthContext) const { login } = actions // ...}
En utilisant le hook useContext
, utilisez les informations contenues dans le state
et vos connaissances pour mettre en place :
- Une redirection de
/upload
vers/login
si l'utilisateur n'est pas identifié- L'utilisateur devra revenir sur
/upload
après identification
- L'utilisateur devra revenir sur
- Une redirection de
/login
vers/gallery
si l'utilisateur est identifié - Une redirection de
/logout
vers/login
si l'utilisateur n'est pas identifié - Le bouton "Connexion" dans la
Navbar
doit devenir un bouton "Déconnexion" et mener à la page/logout
- Le bouton flottant de la page
/gallery
doit être caché si l'utilisateur n'est pas authentifié
Une fois ceci terminé, faites un commit avec le commentaire "Ex 4 : Passing data through context" et poussez vers le serveur.
git add .git commit -m 'Ex 4 : Passing data through context'git push
#
DéconnexionPour se déconnecter, il existe dans l'API un point de terminaison /api/auth/logout
. Il déconnecte l'utilisateur quand il est appelé avec une requête POST.
Utilisez vos connaissances pour faire fonctionner la page /logout
.
Une fois ceci terminé, faites un commit avec le commentaire "Ex 5 : Logging out" et poussez vers le serveur.
git add .git commit -m 'Ex 5 : Logging out'git push
#
Test de connexionVous vous en êtes rendu compte : l'application semble oublier que l'utilisateur est connecté après un rafraîchissement. Pourtant, le système d'authentification est conçu pour que chaque session soit active 24h.
Le problème est très simple : l'application quand elle "démarre" ne peut pas savoir si l'utilisateur est toujours authentifié ou non. Pour ce faire, il nous faudrait faire une requête dès que l'application est chargée vers un point de terminaison de l'API qui nécessite une authentification.
L'API dont vous disposez expose un point de terminaison GET /api/auth/me
qui permet de récupérer l'utilisateur, si il est connecté.
Modifiez votre composant App
de manière à ce qu'il fasse cet appel quand il arrive à l'écran, et mettez à jour l'état de l'application en fonction.
tip
Vous pouvez par exemple utiliser dispatch
avec une action login_success
si la réponse est correcte...
note
Pour que la donnée d'authentification soit bien transmise par fetch
, il faut utiliser l'option credentials
, avec une valeur à same-origin
ou include
.
Une fois ceci terminé, faites un commit avec le commentaire "Ex 5 : Checking auth state" et poussez vers le serveur.
git add .git commit -m 'Ex 5 : Checking auth state'git push
#
Le formulaire de téléversementMaintenant que nous disposons d'une authentification complètement fonctionnelle, nous allons pouvoir passer à la fonctionnalité suivante (et principale !) de notre application : la possibilité d'ajouter des images à la galerie !
Upload
#
Préparer le composant Pour commencer, préparons le composant Upload
qui est affiché sur la page /upload
.
Commencez par transformer l'élément textarea
en élément contrôlé, c'est-à-dire de manière à ce que sa valeur soit gérée par un state.
Faites ensuite en sorte que le composant Upload
appelle une fonction handleSubmit
avec ce state, et que cette dernière affiche l'objet reçu dans la console.
#
Champs fichier et refsComme vous pouvez le constater, le clic sur le bouton "Choisir un fichier" ne fonctionne pas.
Observez le code de notre composant : vous pouvez observer qu'il y a dans notre formulaire un input
de type file
permettant de choisir un fichier, mais celui-ci est caché, pour une raison très simple : ce type d'input
n'est pas pratique à styliser, et toujours assez... moche.
#
DéclenchementNous allons, pour faire fonctionner le téléversement de fichier, simuler un clic de l'utilisateur sur l'input
qui est caché. Comme vous savez, en Javascript, on peut appeler la méthode click
sur l'élément du DOM correspondant. Il s'agit d'une technique impérative.
Pour récupérer l'élément, on pourrait lui donner un id
et utiliser la méthode document.getElementById
, mais ce n'est pas la façon de faire avec React.
On utilise à la place le mécanisme des refs. C'est assez simple : on crée une ref avec le hook useRef
, et on l'assigne à un élément du DOM. On peut ensuite utiliser cette ref pour y accéder de manière impérative.
note
De manière générale, on évite d'utiliser les refs et toute API impérative. Cependant il arrive que, comme dans notre cas, nous y soyons obligés.
Exemple :
SyntaxError: Unexpected token (1:8) 1 : return () ^
Dans cet exemple, nous déclenchons un clic sur le premier bouton lors du clic sur le deuxième.
note
Notez que la valeur courante de la ref, ici l'élément button
, est accessible dans la propriété current
de cette dernière.
Utilisez ce principe pour déclencher un clic sur l'input
type file
lors du clic sur notre bouton "Choisir un fichier".
#
Afficher l'image choisieDans ce type de formulaire, on affiche généralement l'image qui vient d'être sélectionnée.
Pour ce faire, nous allons avoir besoin d'écouter l'évènement change
sur notre input
caché.
L'évènement émis par les champs de fichier est différent des champs habituels. Cependant, on peut récupérer le ou les fichiers choisis dans sa propriété evt.target.files
.
Cet objet sera de type File, un type permettant de manipuler les données binaires. Pour être en mesure de l'afficher, l'idéal pour nous serait de le transformer en URL, que nous pourrions stocker dans notre state, afin de l'utiliser dans une balise img
(comme par exemple celle qui est cachée dans le bouton...).
tip
L'objet URL dispose d'une méthode createObjectURL
permettant de transformer un objet File
en URL...
Faites en sorte de cacher le texte contenu dans le bouton et d'afficher à la place l'image choisie par l'utilisateur.
#
Le téléversementMaintenant, nous avons toutes les cartes en main pour pouvoir effectuer le téléversement de notre formulaire, et ce en AJAX bien sûr !
Voici une fonction uploadForm
que vous pouvez utiliser pour effectuer cette requête. Je ne rentrerai pas dans les détails concernant son fonctionnement, mais faites des recherches ou posez la question si le sujet vous intéresse.
const uploadForm = (user, description, image) => { const formData = new FormData() formData.append('user', user) formData.append('description', description) formData.append('image', image)
return fetch('/api/images', { method: 'POST', body: formData, credentials: 'same-origin' }) .then(res => { if(!res.ok){ throw new Error('Upload failed !') } return res.json() })}
Elle retourne une Promise
, vous pouvez l'utiliser directement de la sorte :
// ...uploadForm(user, description, image) .then(() => { // ... }) .catch(() => { // })
tip
Le paramètre image
attendu est un objet de type File
...
Faites fonctionner le téléversement, puis gérez-en l'état à l'aide d'un reducer conçu pour l'occasion.
À l'aide de cet état, effectuez les modifications suivantes:
- Désactivez les éléments du formulaire si la requête est en cours
- Cachez le formulaire et utiliser le composant
Result
à la place pour afficher à l'utilisateur un succès ou une erreur.
Une fois ceci terminé, faites un commit avec le commentaire "Ex 6 : Uploading images" et poussez vers le serveur.
git add .git commit -m 'Ex 6 : Uploading images'git push
Félicitations, vous êtes arrivés au bout de la première partie de ce TP !