Paso 10: Construyendo la interfaz de usuario

5.0 version
Maintained

Construyendo la interfaz de usuario

Ya está todo listo para crear la primera versión de la interfaz de usuario del sitio web. Por el momento no lo haremos bonito, solo funcional.

¿Recuerdas la llamada a htmlspecialchars() que tuvimos que hacer en el controlador para el huevo de pascua con el fin de evitar problemas de seguridad? Esa es la razón por la que no usaremos PHP para nuestras plantillas. En su lugar, usaremos Twig. Además de ocuparse por nosotros de cambiar las secuencias de caracteres peligrosas por su equivalente inofensivo (escaping), Twig trae un montón de características interesantes que vamos a aprovechar, como la herencia de plantillas.

Instalando Twig

No necesitamos añadir Twig como una dependencia ya que ya ha sido instalado como una dependencia transitiva de EasyAdmin. ¿Pero qué pasa si decides cambiar a otro paquete de administración más adelante? Uno que utilice una API y un front-end de React, por ejemplo. Es probable que ya no dependa de Twig, por lo que Twig se eliminará automáticamente cuando elimines EasyAdmin.

Por si acaso, digámosle a Composer que el proyecto realmente depende de Twig, independientemente de EasyAdmin. Basta con añadirlo como cualquier otra dependencia:

1
$ symfony composer req twig

Ahora Twig forma parte de las dependencias principales del proyecto en 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": {

Usando Twig para las plantillas

Todas las páginas del sitio web compartirán el mismo diseño y distribución de elementos principales (layout). Al instalar Twig, se ha creado un directorio templates/ automáticamente y también se ha incluido un layout de ejemplo en 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 layout puede definir elementos block, que son los lugares donde las plantillas hijo que extienden del layout añaden sus contenidos.

Vamos a crear una plantilla para la página principal del proyecto en 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 %}

La plantilla extiende base.html.twig y redefine los bloques body y title.

La notación {% %} en una plantilla indica acciones y estructura.

La notación {{ }} se utiliza para mostrar algo. {{ conference }} muestra la representación de la conferencia (el resultado de llamar a __toString en el objeto Conference).

Usando Twig en un controlador

Actualiza el controlador para renderizar la plantilla 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(),
+        ]));
     }
 }

Aquí están pasando un montón de cosas.

Para poder renderizar una plantilla, necesitamos el objeto Environment de Twig (el principal punto de entrada de Twig). Observa que solicitamos la instancia de Twig especificándola como parámetro en el método del controlador. Symfony es lo suficientemente inteligente para saber cómo inyectar el objeto correcto.

También necesitamos el repositorio de la entidad Conference para obtener todas las conferencias que hay en la base de datos.

En el código del controlador, el método render() construye y pasa un array de variables a la plantilla. Estamos pasando la lista de objetos Conference como una variable conferences.

Un controlador es una clase estándar de PHP. Ni siquiera necesitamos extender la clase AbstractController si queremos ser explícitos sobre nuestras dependencias. Puedes omitir esa extensión (pero no lo hagas, ya que usaremos los útiles atajos que proporciona en los próximos pasos).

Creando la página para una conferencia

Cada conferencia debe tener una página donde se listen sus comentarios. Añadir una nueva página es cuestión de agregar un controlador, asignarle una ruta y crear la plantilla relacionada.

Agrega un método show() en 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']),
+        ]));
+    }
 }

Este método tiene un comportamiento especial que aún no hemos visto. Pedimos que se inyecte una instancia de Conference en el método. Pero puede haber muchos de estos en la base de datos. Symfony es capaz de determinar cuál quieres basándose en el {id} enviado en la ruta de la solicitud (siendo id la llave primaria de la tabla conference en la base de datos).

Los comentarios relacionados con la conferencia se pueden obtener a través del método findBy() que toma un criterio de búsqueda como primer argumento.

El último paso es crear el fichero: 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 %}

En esta plantilla, estamos usando la notación | para llamar a los filtros de Twig. Un filtro transforma un valor. comments|length devuelve el número de comentarios y comment.createdAt|format_datetime('medium', 'short') da formato a la fecha en una representación legible para el ser humano.

Intenta obtener la «primera» conferencia a través de /conference/1, y observa el siguiente error:

El error viene del filtro format_datetime ya que no es parte del núcleo de Twig. El mensaje de error te da una pista sobre qué paquete debe instalarse para solucionar el problema:

1
$ symfony composer require twig/intl-extra

Ahora la página funciona correctamente.

Vinculando las páginas

El último paso para terminar nuestra primera versión de la interfaz de usuario es vincular las páginas de la conferencia desde la página principal:

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 %}

Sin embargo, especificar en el código una ruta de forma literal es una mala idea por varios motivos. El más importante de ellos es que si cambiara la ruta en el futuro (por ejemplo de /conference/{id} a /conferences/{id}), todos los vínculos deberían actualizarse manualmente.

En su lugar, utiliza la función path() de Twig y usa el nombre de la ruta:

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 función path() genera la ruta a una página utilizando su nombre de ruta. Los valores de los parámetros de la misma se pasan como un mapa de Twig.

Paginando los comentarios

Con miles de asistentes, podemos esperar bastantes comentarios. Si los mostramos todos en una sola página, crecerá muy rápido.

Crea un método llamado getCommentPaginator() en la clase CommentRepository que devuelva un objeto Paginator de comentarios basado en una conferencia y un offset (valor de inicio por el que empezar a listar):

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
     //  */

Hemos establecido el número máximo de comentarios por página en 2 para facilitar las pruebas.

Para gestionar la paginación en la plantilla, pasa a Twig el objeto tipo Paginator de Doctrine en lugar del tipo 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),
         ]));
     }
 }

El controlador obtiene el offset de la petición ($request->query) como un número entero (getInt()), donde por defecto es 0 si no está disponible.

Los intervalos de previous (anterior) y next (siguiente) se calculan en base a toda la información que tenemos del paginador.

Por último, actualiza la plantilla para añadir enlaces a las páginas siguiente y anterior:

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 %}

Ahora podrás navegar por los comentarios a través de los enlaces «Previous» y «Next»:

Refactorizando el controlador

Habrás notado que ambos métodos en ConferenceController toman un entorno (environment) de Twig como argumento. En lugar de inyectarlo en cada método, usemos alguna inyección en el constructor (eso hace que la lista de argumentos sea más corta y menos redundante):

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 Paso 9: Configurando un panel de administración
  • Next » Paso 11: Ramificando el código

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