Étape 10: Construire l’interface

5.0 version
Maintained

Construire l’interface

Tout est maintenant en place pour créer la première version de l’interface du site. On ne la fera pas jolie pour le moment, seulement fonctionnelle.

Vous vous souvenez de l’échappement de caractères que nous avons dû faire dans le contrôleur, pour l”easter egg, afin d’éviter les problèmes de sécurité ? Nous n’utiliserons pas PHP pour nos templates pour cette raison. À la place, nous utiliserons Twig. En plus de gérer l’échappement de caractères, Twig apporte de nombreuses fonctionnalités intéressantes, comme l’héritage des modèles.

Installer Twig

Nous n’avons pas besoin d’ajouter Twig comme dépendance car il a déjà été installé comme dépendance transitive d’EasyAdmin. Mais que se passera-t-il si vous décidez un jour de passer à un autre bundle d’administration ? Un qui utilise une API et un front-end React par exemple ? Il ne dépendra probablement plus de Twig, et Twig sera donc automatiquement supprimé lorsque vous supprimerez EasyAdmin.

Pour faire bonne mesure, disons à Composer que le projet dépend vraiment de Twig, indépendamment d’EasyAdmin. L’ajouter comme n’importe quelle autre dépendance suffit :

1
$ symfony composer req twig

Twig est dorénavant inclus dans les dépendances principales du projet dans le fichier composer.json :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
--- a/composer.json
+++ b/composer.json
@@ -14,6 +14,7 @@
         "symfony/framework-bundle": "4.4.*",
         "symfony/maker-bundle": "^[email protected]",
         "symfony/orm-pack": "dev-master",
+        "symfony/twig-pack": "^1.0",
         "symfony/yaml": "4.4.*"
     },
     "require-dev": {

Utiliser Twig pour les templates

Toutes les pages du site Web suivront le même modèle de mise en page, la même structure HTML de base. Lors de l’installation de Twig, un répertoire templates/ a été créé automatiquement, ainsi qu’un exemple de structure de base dans base.html.twig.

templates/base.html.twig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        {% block stylesheets %}{% endblock %}
    </head>
    <body>
        {% block body %}{% endblock %}
        {% block javascripts %}{% endblock %}
    </body>
</html>

Un modèle peut définir des blocks. Un block est un emplacement où les templates enfants, qui étendent le modèle, ajoutent leur contenu.

Créons un template pour la page d’accueil du projet dans templates/conference/index.html.twig :

templates/conference/index.html.twig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{% extends 'base.html.twig' %}

{% block title %}Conference Guestbook{% endblock %}

{% block body %}
    <h2>Give your feedback!</h2>

    {% for conference in conferences %}
        <h4>{{ conference }}</h4>
    {% endfor %}
{% endblock %}

Le template étend (ou extends) base.html.twig et redéfinit les blocs title et body.

La notation {% %} dans un template indique des actions et des éléments de structure.

La notation {{ }} est utilisée pour afficher quelque chose. {{ conference }} affiche la représentation de la conférence (le résultat de l’appel à la méthode``__toString`` de l’objet Conference).

Utiliser Twig dans un contrôleur

Mettez à jour le contrôleur pour générer le contenu du template Twig :

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -2,24 +2,21 @@

 namespace App\Controller;

+use App\Repository\ConferenceRepository;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;
+use Twig\Environment;

 class ConferenceController extends AbstractController
 {
     /**
      * @Route("/", name="homepage")
      */
-    public function index()
+    public function index(Environment $twig, ConferenceRepository $conferenceRepository)
     {
-        return new Response(<<<EOF
-<html>
-    <body>
-        <img src="/images/under-construction.gif" />
-    </body>
-</html>
-EOF
-        );
+        return new Response($twig->render('conference/index.html.twig', [
+            'conferences' => $conferenceRepository->findAll(),
+        ]));
     }
 }

Il se passe beaucoup de choses ici.

Pour pouvoir générer le contenu du template, nous avons besoin de l’objet Environment de Twig (le point d’entrée principal de Twig). Notez que nous demandons l’instance Twig en spécifiant son type dans la méthode du contrôleur. Symfony est assez intelligent pour savoir comment injecter le bon objet.

Nous avons également besoin du repository des conférences pour récupérer toutes les conférences depuis la base de données.

Dans le code du contrôleur, la méthode render() génère le rendu du template et lui passe un tableau de variables. Nous passons la liste des objets Conference dans une variable conferences.

Un contrôleur est une classe PHP standard. Nous n’avons même pas besoin d’étendre la classe AbstractController si nous voulons être explicites sur nos dépendances. Vous pouvez donc supprimer l’héritage (mais ne le faites pas, car nous utiliserons les raccourcis qu’il fournit dans les prochaines étapes).

Créer la page d’une conférence

Chaque conférence devrait avoir une page dédiée à l’affichage de ses commentaires. L’ajout d’une nouvelle page consiste à ajouter un contrôleur, à définir une route et à créer le template correspondant.

Ajoutez une méthode show() dans le fichier src/Controller/ConferenceController.php :

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -2,7 +2,9 @@

 namespace App\Controller;

+use App\Entity\Conference;
+use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;
@@ -19,4 +21,15 @@ class ConferenceController extends AbstractController
             'conferences' => $conferenceRepository->findAll(),
         ]));
     }
+
+    /**
+     * @Route("/conference/{id}", name="conference")
+     */
+    public function show(Environment $twig, Conference $conference, CommentRepository $commentRepository)
+    {
+        return new Response($twig->render('conference/show.html.twig', [
+            'conference' => $conference,
+            'comments' => $commentRepository->findBy(['conference' => $conference], ['createdAt' => 'DESC']),
+        ]));
+    }
 }

Cette méthode a un comportement particulier que nous n’avons pas encore vu. Nous demandons qu’une instance de Conference soit injectée dans la méthode. Mais il y en a peut-être beaucoup dans la base de données. Symfony est capable de déterminer celle que vous voulez en se basant sur l”{id} passé dans le chemin de la requête (id étant la clé primaire de la table conference dans la base de données).

La récupération des commentaires associés à la conférence peut se faire via la méthode findBy(), qui prend un critère comme premier argument.

La dernière étape consiste à créer le fichier templates/conference/show.html.twig :

templates/conference/show.html.twig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{% extends 'base.html.twig' %}

{% block title %}Conference Guestbook - {{ conference }}{% endblock %}

{% block body %}
    <h2>{{ conference }} Conference</h2>

    {% if comments|length > 0 %}
        {% for comment in comments %}
            {% if comment.photofilename %}
                <img src="{{ asset('uploads/photos/' ~ comment.photofilename) }}" />
            {% endif %}

            <h4>{{ comment.author }}</h4>
            <small>
                {{ comment.createdAt|format_datetime('medium', 'short') }}
            </small>

            <p>{{ comment.text }}</p>
        {% endfor %}
    {% else %}
        <div>No comments have been posted yet for this conference.</div>
    {% endif %}
{% endblock %}

Dans ce template, nous utilisons le symbole | pour appeler les filtres Twig. Un filtre transforme une valeur. comments|length retourne le nombre de commentaires et comment.createdAt|format_datetime('medium', 'short') affiche la date dans un format lisible par l’internaute.

Essayez d’afficher la « première » conférence en naviguant vers /conference/1, et constatez l’erreur suivante :

L’erreur vient du filtre format_datetime, qui ne fait pas partie du noyau de Twig. Le message d’erreur vous donne un indice sur le paquet à installer pour résoudre le problème :

1
$ symfony composer require twig/intl-extra

Maintenant la page fonctionne correctement.

Lier des pages entre elles

La toute dernière étape pour terminer notre première version de l’interface est de rendre les pages de la conférence accessibles depuis la page d’accueil :

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/templates/conference/index.html.twig
+++ b/templates/conference/index.html.twig
@@ -7,5 +7,8 @@

     {% for conference in conferences %}
         <h4>{{ conference }}</h4>
+        <p>
+            <a href="/conference/{{ conference.id }}">View</a>
+        </p>
     {% endfor %}
 {% endblock %}

Mais coder un chemin en dur est une mauvaise idée pour plusieurs raisons. La raison principale est que si vous transformez le chemin (de /conference/{id} en /conferences/{id} par exemple), tous les liens doivent être mis à jour manuellement.

Utilisez plutôt la fonction Twig path() avec le nom de la route :

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/templates/conference/index.html.twig
+++ b/templates/conference/index.html.twig
@@ -8,7 +8,7 @@
     {% for conference in conferences %}
         <h4>{{ conference }}</h4>
         <p>
-            <a href="/conference/{{ conference.id }}">View</a>
+            <a href="{{ path('conference', { id: conference.id }) }}">View</a>
         </p>
     {% endfor %}
 {% endblock %}

La fonction path() génère le chemin d’accès vers une page à l’aide du nom de la route. Les valeurs des paramètres dynamiques de la route sont transmises sous la forme d’un objet Twig.

Paginer les commentaires

Avec des milliers de personnes présentes, on peut s’attendre à un nombre important de commentaires. Si nous les affichons tous sur une seule page, elle deviendra rapidement énorme.

Créez une méthode getCommentPaginator() dans CommentRepository. Cette méthode renvoie un Paginator de commentaires basé sur une conférence et un décalage (où commencer) :

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
--- a/src/Repository/CommentRepository.php
+++ b/src/Repository/CommentRepository.php
@@ -3,8 +3,10 @@
 namespace App\Repository;

 use App\Entity\Comment;
+use App\Entity\Conference;
 use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 use Doctrine\Persistence\ManagerRegistry;
+use Doctrine\ORM\Tools\Pagination\Paginator;

 /**
  * @method Comment|null find($id, $lockMode = null, $lockVersion = null)
@@ -14,11 +16,27 @@ use Doctrine\Persistence\ManagerRegistry;
  */
 class CommentRepository extends ServiceEntityRepository
 {
+    public const PAGINATOR_PER_PAGE = 2;
+
     public function __construct(ManagerRegistry $registry)
     {
         parent::__construct($registry, Comment::class);
     }

+    public function getCommentPaginator(Conference $conference, int $offset): Paginator
+    {
+        $query = $this->createQueryBuilder('c')
+            ->andWhere('c.conference = :conference')
+            ->setParameter('conference', $conference)
+            ->orderBy('c.createdAt', 'DESC')
+            ->setMaxResults(self::PAGINATOR_PER_PAGE)
+            ->setFirstResult($offset)
+            ->getQuery()
+        ;
+
+        return new Paginator($query);
+    }
+
     // /**
     //  * @return Comment[] Returns an array of Comment objects
     //  */

Nous avons fixé le nombre maximum de commentaires par page à 2 pour faciliter les tests.

Pour gérer la pagination dans le template, transmettez à Twig le Doctrine Paginator au lieu de la Doctrine Collection :

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -6,6 +6,7 @@ use App\Entity\Conference;
 use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;
 use Twig\Environment;
@@ -25,11 +26,16 @@ class ConferenceController extends AbstractController
     /**
      * @Route("/conference/{id}", name="conference")
      */
-    public function show(Environment $twig, Conference $conference, CommentRepository $commentRepository)
+    public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository)
     {
+        $offset = max(0, $request->query->getInt('offset', 0));
+        $paginator = $commentRepository->getCommentPaginator($conference, $offset);
+
         return new Response($twig->render('conference/show.html.twig', [
             'conference' => $conference,
-            'comments' => $commentRepository->findBy(['conference' => $conference], ['createdAt' => 'DESC']),
+            'comments' => $paginator,
+            'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,
+            'next' => min(count($paginator), $offset + CommentRepository::PAGINATOR_PER_PAGE),
         ]));
     }
 }

Le contrôleur récupère la valeur du décalage (offset) depuis les paramètres de l’URL ($request->query) sous forme d’entier (getInt()). Par défaut, sa valeur sera 0 si le paramètre n’est pas défini.

Les décalages précédent et suivant sont calculés sur la base de toutes les informations que nous avons reçues du paginateur.

Enfin, mettez à jour le template pour ajouter des liens vers les pages suivantes et précédentes :

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
index 0c9e7d2..14b51fd 100644
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -6,6 +6,8 @@
     <h2>{{ conference }} Conference</h2>

     {% if comments|length > 0 %}
+        <div>There are {{ comments|length }} comments.</div>
+
         {% for comment in comments %}
             {% if comment.photofilename %}
                 <img src="{{ asset('uploads/photos/' ~ comment.photofilename) }}" />
@@ -18,6 +20,13 @@

             <p>{{ comment.text }}</p>
         {% endfor %}
+
+        {% if previous >= 0 %}
+            <a href="{{ path('conference', { id: conference.id, offset: previous }) }}">Previous</a>
+        {% endif %}
+        {% if next < comments|length %}
+            <a href="{{ path('conference', { id: conference.id, offset: next }) }}">Next</a>
+        {% endif %}
     {% else %}
         <div>No comments have been posted yet for this conference.</div>
     {% endif %}

Vous devriez maintenant pouvoir naviguer dans les commentaires avec les liens « Previous » et « Next » :

Optimiser le contrôleur

Vous avez peut-être remarqué que les deux méthodes présentes dans ConferenceController prennent un environnement Twig comme argument. Au lieu de l’injecter dans chaque méthode, utilisons plutôt une injection dans le constructeur (ce qui rend la liste des arguments plus courte et moins redondante) :

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -13,12 +13,19 @@ use Twig\Environment;

 class ConferenceController extends AbstractController
 {
+    private $twig;
+
+    public function __construct(Environment $twig)
+    {
+        $this->twig = $twig;
+    }
+
     /**
      * @Route("/", name="homepage")
      */
-    public function index(Environment $twig, ConferenceRepository $conferenceRepository)
+    public function index(ConferenceRepository $conferenceRepository)
     {
-        return new Response($twig->render('conference/index.html.twig', [
+        return new Response($this->twig->render('conference/index.html.twig', [
             'conferences' => $conferenceRepository->findAll(),
         ]));
     }
@@ -26,12 +33,12 @@ class ConferenceController extends AbstractController
     /**
      * @Route("/conference/{id}", name="conference")
      */
-    public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository)
+    public function show(Request $request, Conference $conference, CommentRepository $commentRepository)
     {
         $offset = max(0, $request->query->getInt('offset', 0));
         $paginator = $commentRepository->getCommentPaginator($conference, $offset);

-        return new Response($twig->render('conference/show.html.twig', [
+        return new Response($this->twig->render('conference/show.html.twig', [
             'conference' => $conference,
             'comments' => $paginator,
             'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,

  • « Previous Étape 9: Configurer une interface d’administration
  • Next » Étape 11: Utiliser des branches

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.