Skip to main content

TP4 - Image Gallery - Partie 1

Introduction#

Vous 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 TP#

Lors 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 code#

Pour 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 Gitlab#

Cré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 composants#

Comme 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 serveur#

Pour 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 routage#

A 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 :

  • / : page Home
  • /gallery : page Gallery
  • /login : page Login
  • /logout : page Logout
  • /upload : page Upload

Modifiez ensuite le composant NavBar de manière à utiliser la composant Link ainsi que les URL adaptées.

Les composants visuels#

Modifiez chacun des composants visuels suivants de manière à ce qu'il puisse être utilisé de la façon spécifiée.

Navbar#

La 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.

Alert#

Ce 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" />

Thumbnail#

Modifiez le composant Gallery de la sorte :

src/pages/Gallery.jsx
{/* ... */}<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.

LoginForm#

Modifiez le composant Login de la façon suivante :

src/pages/Login.jsx
// ...<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 galerie#

Pour commencer les choses sérieuses, nous allons travailler sur l'affichage de la galerie de photos.

Afficher une liste d'images#

Le 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 serveur#

Maintenant 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'authentification#

Notre 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 login#

L'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

La documentation de fetch explique comment faire une requête POST avec des données... Elle explique aussi le contenu de l'objet Response, en particulier sa propriété ok...

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

Reducers#

Une 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 :

Live Editor
Result
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 formulaire
  • login_success: quand l'authentification a réussi
  • login_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 global#

Maintenant que nous savons authentifier notre utilisateur, nous allons devoir utiliser cette information dans l'ensemble de notre application : la Navbar, la page Upload...

Remonter le state#

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 contexte#

Vous 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 conditionnel#

Ainsi, 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
  • 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éconnexion#

Pour 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 connexion#

Vous 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éversement#

Maintenant 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 !

Préparer le composant Upload#

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 refs#

Comme 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éclenchement#

Nous 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 :

Live Editor
Result
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 choisie#

Dans 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éversement#

Maintenant, 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 !