Paginación y ordenación por columnas
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login Subscribe¡Bienvenido al Día 13! Hoy vamos a dejar de lado Stimulus y Turbo y trabajaremos únicamente con Symfony y Twig. Nuestro objetivo es añadir paginación y ordenación por columnas a esta lista.
Añadir paginación
Me gusta añadir paginación con Pagerfanta. Me encanta esta librería, aunque me pierdo un poco en su documentación. Pero oye: es de código abierto, si no estás contento, ¡ve y arréglalo!
Para utilizar Pagerfanta, instalaremos tres bibliotecas:
composer require babdev/pagerfanta-bundle pagerfanta/doctrine-orm-adapter pagerfanta/twig
¡Genial! Primero hagamos funcionar la parte PHP. Abre src/Controller/MainController.php. La página actual se almacenará en la URL como ?page=1 o ?page=2, así que necesitamos leer ese parámetro de consulta page. Lo haremos con un nuevo atributo#[MapQueryParameter]. Y en realidad, antes... Estaba haciendo demasiado trabajo. Si tu parámetro de consulta coincide con el nombre de tu argumento, no necesitas especificarlo ahí. Así que lo suprimiré en esos dos. Es diferente para searchPlanet: un parámetro que utilizaremos más adelante.
De todos modos, esto leerá el ?page= y lo pondremos por defecto a 1. Ah, y el orden de estos no importa:
| // ... lines 1 - 12 | |
| use Symfony\Component\Routing\Annotation\Route; | |
| class MainController extends AbstractController | |
| { | |
| ('/', name: 'app_homepage') | |
| public function homepage( | |
| // ... lines 19 - 20 | |
| #[MapQueryParameter] int $page = 1, | |
| #[MapQueryParameter] string $query = null, | |
| #[MapQueryParameter('planets', \FILTER_VALIDATE_INT)] array $searchPlanets = [], | |
| ): Response | |
| { | |
| // ... lines 26 - 37 | |
| } |
A continuación, copia la línea $voyageRepository->findBySearch() y sustitúyela por un objeto Pager: $pager es igual a Pagerfanta::createForCurrentPageWithMaxPerPage():
| // ... lines 1 - 7 | |
| use Pagerfanta\Pagerfanta; | |
| // ... lines 9 - 14 | |
| class MainController extends AbstractController | |
| { | |
| // ... line 17 | |
| public function homepage( | |
| // ... lines 19 - 23 | |
| ): Response | |
| { | |
| $pager = Pagerfanta::createForCurrentPageWithMaxPerPage( | |
| // ... lines 27 - 29 | |
| ); | |
| // ... lines 31 - 36 | |
| } | |
| } |
El primer argumento es un adaptador: nuevo QueryAdapter luego pega el código de antes. Pero, eso no es del todo correcto: este método devuelve una matriz de viajes:
| // ... lines 1 - 17 | |
| class VoyageRepository extends ServiceEntityRepository | |
| { | |
| // ... lines 20 - 24 | |
| /** | |
| * @return Voyage[] | |
| */ | |
| public function findBySearch(?string $query, array $searchPlanets, int $limit = null): array | |
| { | |
| $qb = $this->findBySearchQueryBuilder($query, $searchPlanets); | |
| if ($limit) { | |
| $qb->setMaxResults($limit); | |
| } | |
| return $qb | |
| ->getQuery() | |
| ->getResult(); | |
| } | |
| // ... lines 40 - 60 | |
| } |
pero ahora necesitamos un QueryBuilder. Afortunadamente, ya he configurado las cosas para que podamos obtener este mismo resultado, pero como un QueryBuilder a través de: findBySearchQueryBuilder:
| // ... lines 1 - 17 | |
| class VoyageRepository extends ServiceEntityRepository | |
| { | |
| // ... lines 20 - 40 | |
| public function findBySearchQueryBuilder(?string $query, array $searchPlanets, ?string $sort = null, string $direction = 'DESC'): QueryBuilder | |
| { | |
| $qb = $this->createQueryBuilder('v'); | |
| if ($query) { | |
| $qb->andWhere('v.purpose LIKE :query') | |
| ->setParameter('query', '%' . $query . '%'); | |
| } | |
| if ($searchPlanets) { | |
| $qb->andWhere('v.planet IN (:planets)') | |
| ->setParameter('planets', $searchPlanets); | |
| } | |
| if ($sort) { | |
| $qb->orderBy('v.' . $sort, $direction); | |
| } | |
| return $qb; | |
| } | |
| } |
Pega ese nombre de método.
El siguiente argumento es la página actual - $page - y luego el máximo por página. ¿Qué te parece 10?
| // ... lines 1 - 6 | |
| use Pagerfanta\Doctrine\ORM\QueryAdapter; | |
| // ... lines 8 - 14 | |
| class MainController extends AbstractController | |
| { | |
| // ... line 17 | |
| public function homepage( | |
| // ... lines 19 - 23 | |
| ): Response | |
| { | |
| $pager = Pagerfanta::createForCurrentPageWithMaxPerPage( | |
| new QueryAdapter($voyageRepository->findBySearchQueryBuilder($query, $searchPlanets)), | |
| $page, | |
| 10 | |
| ); | |
| // ... lines 31 - 36 | |
| } | |
| } |
Pasa $pager a la plantilla como la variable voyages:
| // ... lines 1 - 14 | |
| class MainController extends AbstractController | |
| { | |
| // ... line 17 | |
| public function homepage( | |
| // ... lines 19 - 23 | |
| ): Response | |
| { | |
| $pager = Pagerfanta::createForCurrentPageWithMaxPerPage( | |
| new QueryAdapter($voyageRepository->findBySearchQueryBuilder($query, $searchPlanets)), | |
| $page, | |
| 10 | |
| ); | |
| return $this->render('main/homepage.html.twig', [ | |
| 'voyages' => $pager, | |
| // ... lines 34 - 35 | |
| ]); | |
| } | |
| } |
Eso... debería funcionar porque podemos hacer un bucle sobre $pager para obtener los viajes.
Renderizar los enlaces de paginación
A continuación, en homepage.html.twig, ¡necesitamos enlaces de paginación! En la parte inferior, ya tengo un lugar para ello con los enlaces anterior y siguiente codificados:
| // ... lines 1 - 4 | |
| {% block body %} | |
| <div class="flex"> | |
| // ... lines 7 - 13 | |
| <section class="flex-1 ml-10"> | |
| // ... lines 15 - 82 | |
| <div class="flex items-center mt-6 space-x-4"> | |
| <a href="#" class="block py-2 px-4 bg-gray-700 text-white rounded hover:bg-gray-600">Previous</a> | |
| <a href="#" class="block py-2 px-4 bg-gray-700 text-white rounded hover:bg-gray-600">Next</a> | |
| </div> | |
| </section> | |
| </div> | |
| {% endblock %} |
La forma en que se supone que debes mostrar los enlaces Pagerfanta es diciendo {{ pagerfanta() }}y pasando voyages:
| // ... lines 1 - 4 | |
| {% block body %} | |
| <div class="flex"> | |
| // ... lines 7 - 13 | |
| <section class="flex-1 ml-10"> | |
| // ... lines 15 - 82 | |
| <div class="flex items-center mt-6 space-x-4"> | |
| {{ pagerfanta(voyages) }} | |
| <a href="#" class="block py-2 px-4 bg-gray-700 text-white rounded hover:bg-gray-600">Previous</a> | |
| <a href="#" class="block py-2 px-4 bg-gray-700 text-white rounded hover:bg-gray-600">Next</a> | |
| </div> | |
| </section> | |
| </div> | |
| // ... lines 90 - 91 |
Cuando probamos esto -permíteme aclarar mi búsqueda- la paginación tiene un aspecto horrible... ¡pero funciona! A medida que hacemos clic, los resultados van cambiando.
Entonces... ¿cómo podemos cambiar estos enlaces de paginación de "bla" a "ah"? Hay una plantilla Tailwind incorporada que puedes decirle a Pagerfanta que utilice. Eso implica crear un archivo babdev_pagerfanta.yaml y un poco de configuración. No he utilizado esto antes, así que ¡hazme saber cómo va!
babdev_pagerfanta:
# The default Pagerfanta view to use in your application
default_view: twig
# The default Twig template to use when using the Twig Pagerfanta view
default_twig_template: '@BabDevPagerfanta/tailwind.html.twig'
Porque... Voy a ser testaruda. Sólo quiero tener los botones anterior y siguiente... y quiero que tengan el mismo estilo que estos. Así que ¡vamos al lío!
Lo primero que tenemos que hacer es mostrar estos enlaces condicionalmente, sólo si hay una página anterior. Para ello, di if voyages.hasPreviousPage, then render. Y, si tenemos una página siguiente, renderiza esa:
| // ... lines 1 - 4 | |
| {% block body %} | |
| <div class="flex"> | |
| // ... lines 7 - 13 | |
| <section class="flex-1 ml-10"> | |
| // ... lines 15 - 82 | |
| <div class="flex items-center mt-6 space-x-4"> | |
| {% if voyages.hasPreviousPage %} | |
| <a href="#" class="block py-2 px-4 bg-gray-700 text-white rounded hover:bg-gray-600">Previous</a> | |
| {% endif %} | |
| {% if voyages.hasNextPage %} | |
| <a href="#" class="block py-2 px-4 bg-gray-700 text-white rounded hover:bg-gray-600">Next</a> | |
| {% endif %} | |
| </div> | |
| </section> | |
| </div> | |
| {% endblock %} |
Para las URL, utiliza un ayudante llamado pagerfanta_page_url(). Pásale el paginador,voyages, y luego la página a la que queremos ir: voyages.previousPage. Cópialo y repítelo a continuación con voyages.nextPage:
| // ... lines 1 - 4 | |
| {% block body %} | |
| <div class="flex"> | |
| // ... lines 7 - 13 | |
| <section class="flex-1 ml-10"> | |
| // ... lines 15 - 82 | |
| <div class="flex items-center mt-6 space-x-4"> | |
| {% if voyages.hasPreviousPage %} | |
| <a href="{{ pagerfanta_page_url(voyages, voyages.previousPage) }}" class="block py-2 px-4 bg-gray-700 text-white rounded hover:bg-gray-600">Previous</a> | |
| {% endif %} | |
| {% if voyages.hasNextPage %} | |
| <a href="{{ pagerfanta_page_url(voyages, voyages.nextPage) }}" class="block py-2 px-4 bg-gray-700 text-white rounded hover:bg-gray-600">Next</a> | |
| {% endif %} | |
| // ... lines 90 - 92 | |
| </div> | |
| </section> | |
| </div> | |
| {% endblock %} |
¡Estupendo! Vamos a probarlo. Refrescar. ¡Me encanta! Falta la página anterior, hacemos clic en siguiente y ya está. Vuelve a hacer clic en siguiente. La página 3 es la última. ¡Ya está!
Para obtener un crédito extra, imprimamos incluso la página actual. Añade un div... y luego imprimevoyages.currentPage, una barra y voyages.nbPages:
| // ... lines 1 - 4 | |
| {% block body %} | |
| <div class="flex"> | |
| // ... lines 7 - 13 | |
| <section class="flex-1 ml-10"> | |
| // ... lines 15 - 82 | |
| <div class="flex items-center mt-6 space-x-4"> | |
| {% if voyages.hasPreviousPage %} | |
| <a href="{{ pagerfanta_page_url(voyages, voyages.previousPage) }}" class="block py-2 px-4 bg-gray-700 text-white rounded hover:bg-gray-600">Previous</a> | |
| {% endif %} | |
| {% if voyages.hasNextPage %} | |
| <a href="{{ pagerfanta_page_url(voyages, voyages.nextPage) }}" class="block py-2 px-4 bg-gray-700 text-white rounded hover:bg-gray-600">Next</a> | |
| {% endif %} | |
| <div class="ml-4"> | |
| Page {{ voyages.currentPage }}/{{ voyages.nbPages }} | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| {% endblock %} |
¡Buen trabajo, IA!
Y... ya está. Página 1 de 3. Página 2 de 3.
Ordenación por columnas
¿Qué pasa con la ordenación por columnas? Quiero poder hacer clic en cada columna para ordenar por ella. Para ello, necesitamos dos nuevos parámetros de consulta. Un nombre de columna sort ysortDirection. ¡Vuelve a PHP! Añade #[MapQueryParameter] a un argumento string llamado $sort. Pon por defecto leaveAt. Ése es el nombre de la propiedad de esta columna de salida. A continuación, vuelve a hacer #[MapQueryParameter] para añadir una cadena$sortDirection que por defecto sea ascendente:
| // ... lines 1 - 14 | |
| class MainController extends AbstractController | |
| { | |
| ('/', name: 'app_homepage') | |
| public function homepage( | |
| // ... lines 19 - 21 | |
| #[MapQueryParameter] string $sort = 'leaveAt', | |
| #[MapQueryParameter] string $sortDirection = 'ASC', | |
| // ... lines 24 - 25 | |
| ): Response | |
| { | |
| // ... lines 28 - 42 | |
| } | |
| } |
Dentro del método, pegaré 2 líneas aburridas que validan que sort es una columna real:
| // ... lines 1 - 14 | |
| class MainController extends AbstractController | |
| { | |
| ('/', name: 'app_homepage') | |
| public function homepage( | |
| // ... lines 19 - 25 | |
| ): Response | |
| { | |
| $validSorts = ['purpose', 'leaveAt']; | |
| $sort = in_array($sort, $validSorts) ? $sort : 'leaveAt'; | |
| // ... lines 30 - 42 | |
| } | |
| } |
Probablemente podríamos hacer lo mismo para $sortDirection, pero me lo saltaré y pasaré afindBySearchQueryBuilder(). Esto ya está configurado para esperar los argumentos de ordenación. Así que pasa $sort y $sortDirection... ¡y debería estar contento!
| // ... lines 1 - 14 | |
| class MainController extends AbstractController | |
| { | |
| ('/', name: 'app_homepage') | |
| public function homepage( | |
| // ... lines 19 - 25 | |
| ): Response | |
| { | |
| $validSorts = ['purpose', 'leaveAt']; | |
| $sort = in_array($sort, $validSorts) ? $sort : 'leaveAt'; | |
| $pager = Pagerfanta::createForCurrentPageWithMaxPerPage( | |
| new QueryAdapter($voyageRepository->findBySearchQueryBuilder($query, $searchPlanets, $sort, $sortDirection)), | |
| // ... lines 32 - 33 | |
| ); | |
| // ... lines 35 - 42 | |
| } | |
| } |
Por último, vamos a necesitar esta información en la plantilla para ayudar a mostrar los enlaces de ordenación. Pasa sort ajustado a $sort y sortDirection ajustado a $sortDirection:
| // ... lines 1 - 14 | |
| class MainController extends AbstractController | |
| { | |
| ('/', name: 'app_homepage') | |
| public function homepage( | |
| // ... lines 19 - 25 | |
| ): Response | |
| { | |
| // ... lines 28 - 35 | |
| return $this->render('main/homepage.html.twig', [ | |
| // ... lines 37 - 39 | |
| 'sort' => $sort, | |
| 'sortDirection' => $sortDirection, | |
| ]); | |
| } | |
| } |
Añadir los enlaces de ordenación de columnas
La parte más tediosa es transformar cada th en el enlace de ordenación adecuado. Añade una etiqueta a y divídela en varias líneas. Establece el href en esta página -la página principal- con un sort adicional establecido en purpose: el nombre de esta columna. Para sortDirection, esto es más complejo: si sort es igual a purposey sortDirection es asc, entonces queremos desc. En caso contrario, utiliza asc.
Por último, además de los parámetros de consulta sort y sortDirection, necesitamos mantener cualquier parámetro de consulta existente que pueda estar presente, como la consulta de búsqueda. Y hay una forma genial de hacerlo: ... y luego app.request.query.all:
| // ... lines 1 - 27 | |
| {% block body %} | |
| <div class="flex"> | |
| // ... lines 30 - 36 | |
| <section class="flex-1 ml-10"> | |
| // ... lines 38 - 55 | |
| <div class="bg-gray-800 p-4 rounded"> | |
| <table class="w-full text-white"> | |
| <thead> | |
| <tr> | |
| <th class="text-left py-2"> | |
| <a href="{{ path('app_homepage', { | |
| ...app.request.query.all(), | |
| sort: 'purpose', | |
| sortDirection: sort == 'purpose' and sortDirection == 'asc' ? 'desc' : 'asc', | |
| }) }}"> | |
| // ... line 66 | |
| </a> | |
| </th> | |
| // ... lines 69 - 78 | |
| </tr> | |
| </thead> | |
| // ... lines 81 - 119 | |
| </table> | |
| </div> | |
| // ... lines 122 - 132 | |
| </section> | |
| </div> | |
| {% endblock %} |
¡Listo! Ah, pero después de la palabra Propósito, añadamos una bonita flecha hacia abajo o hacia arriba. Para ayudarte, pegaré una macro Twig. No suelo utilizar macros... pero esto nos ayudará a averiguar la dirección, y luego imprimir el SVG correcto: una flecha hacia abajo, una flecha hacia arriba, o una flecha hacia arriba y otra hacia abajo:
| // ... lines 1 - 4 | |
| {% macro sortArrow(sortName, sort, sortDirection) %} | |
| {% if sort == sortName %} | |
| {% if sortDirection == 'asc' %} | |
| <svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-4 h-4" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> | |
| <path stroke="none" d="M0 0h24v24H0z" fill="none"></path> | |
| <path d="M6 15l6 -6l6 6"></path> | |
| </svg> | |
| {% else %} | |
| <svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-4 h-4" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> | |
| <path stroke="none" d="M0 0h24v24H0z" fill="none"></path> | |
| <path d="M6 9l6 6l6 -6"></path> | |
| </svg> | |
| {% endif %} | |
| {% else %} | |
| <!-- up and down arrow svg --> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-4 h-4 text-slate-300" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> | |
| <path stroke="none" d="M0 0h24v24H0z" fill="none"></path> | |
| <path d="M8 9l4 -4l4 4"></path> | |
| <path d="M16 15l-4 4l-4 -4"></path> | |
| </svg> | |
| {% endif %} | |
| {% endmacro %} | |
| // ... lines 27 - 136 |
Aquí abajo... utiliza esto con {{ _self.sortArrow() }} pasando por 'purpose',sort y sortDirection:
| // ... lines 1 - 27 | |
| {% block body %} | |
| <div class="flex"> | |
| // ... lines 30 - 36 | |
| <section class="flex-1 ml-10"> | |
| // ... lines 38 - 55 | |
| <div class="bg-gray-800 p-4 rounded"> | |
| <table class="w-full text-white"> | |
| <thead> | |
| <tr> | |
| <th class="text-left py-2"> | |
| <a href="{{ path('app_homepage', { | |
| ...app.request.query.all(), | |
| sort: 'purpose', | |
| sortDirection: sort == 'purpose' and sortDirection == 'asc' ? 'desc' : 'asc', | |
| }) }}"> | |
| Purpose {{ _self.sortArrow('purpose', sort, sortDirection) }} | |
| </a> | |
| </th> | |
| // ... lines 69 - 78 | |
| </tr> | |
| </thead> | |
| // ... lines 81 - 119 | |
| </table> | |
| </div> | |
| // ... lines 122 - 132 | |
| </section> | |
| </div> | |
| {% endblock %} |
¡Uf! Repitamos todo esto para la columna de salida. Pega, cambia purposepor leaveAt, el texto por Departing... luego utiliza leaveAt en los otros dos puntos:
| // ... lines 1 - 27 | |
| {% block body %} | |
| <div class="flex"> | |
| // ... lines 30 - 36 | |
| <section class="flex-1 ml-10"> | |
| // ... lines 38 - 55 | |
| <div class="bg-gray-800 p-4 rounded"> | |
| <table class="w-full text-white"> | |
| <thead> | |
| <tr> | |
| // ... lines 60 - 69 | |
| <th class="text-left py-2"> | |
| <a href="{{ path('app_homepage', { | |
| ...app.request.query.all(), | |
| sort: 'leaveAt', | |
| sortDirection: sort == 'leaveAt' and sortDirection == 'asc' ? 'desc' : 'asc', | |
| }) }}"> | |
| Departing {{ _self.sortArrow('leaveAt', sort, sortDirection) }} | |
| </a> | |
| </th> | |
| </tr> | |
| </thead> | |
| // ... lines 81 - 119 | |
| </table> | |
| </div> | |
| // ... lines 122 - 132 | |
| </section> | |
| </div> | |
| {% endblock %} |
Así pues, todo un código bastante aburrido, aunque ha costado un poco de trabajo configurarlo. ¿Podríamos tener algunas herramientas en el mundo Symfony para que todo esto fuera más fácil de construir? Probablemente, sería genial que alguien trabajara en ello.
¡El momento de la verdad! Actualiza. Tiene buena pinta. ¡Y funciona de maravilla! Podemos ordenar por cada columna... podemos paginar. El filtrado mantiene nuestra página... y mantiene el parámetro de búsqueda. ¡Es todo lo que quiero! ¡Y todo ocurre mediante Ajax! ¡La vida es buena!
¿El único contratiempo ahora? Ese incómodo desplazamiento cada vez que hacemos algo. Quiero que esto parezca una aplicación independiente que no salta de un lado a otro. Mañana: puliremos esto gracias a Turbo Frames.
10 Comments
To save a few lines of code, you could of course also work with a macro for the table header. For example, I built the following macro according to this tutorial:
Hi @KB
Yeah that is a good example for twig macro usage, thanks for sharing it!
Cheers!
I was encountering a bug on the arrow of the column sorting the first time I load the page (wrong direction).
Just found that it was due to ASC (in uppercase) in the controller vs asc/desc (in lowercase) in the Twig macro !
Hey @Quentin
Oh, right, it makes sense because
ascis not equal (===) toASC. When I doubt what type of string I'm getting, I transform it into lowercase first.Cheers!
Ryan,
I'm trying to use LiveComponent to sort my columns. I have no problem passing the
sortDirectionto the live component, and I use a small Stimulus controller to flip from 'ASC' to 'DESC', but I'm struggling to figure out how to also send the column I want to sort by with the direction, this is my button:Any suggestions?
Hey @Brandon!
Try changing this to trigger a
#[LiveAction]instead - e.g.public function sort(string $direction, string $field). Inside, you'll use those 2 arguments to set those 2 properties. On the frontend, thedata-action-nameattribute allows you to specify which live action you want to call and its arguments.Let me know if this helps!
Cheers!
Ryan,
On the frontend, the data-action-name attribute allows you to specify which live action you want to call and its arguments.
How do I send multiple arguments? Can't the button only have one
data-modelanddata-value?In my component I have this:
And my button has no problem sending the
data-model="sortDirection"&data-value="DESC". Should I change this to a form and have two hidden fields?Hey @Brandon!
It should look like this:
The confusion, I think, is that because you want to change 2 models with one click, we're going to completely forgo
data-modeland instead just trigger aLiveAction(which can then change the models for us). So nodata-modelneeded at all.FYI - in the next release (2.14.0),
data-action-namewill go away in favor of another syntax. Updating will involve a simple "find" fordata-action-namein your app and a syntax change. So nothing fundamental is changing - but I wanted to give you a head's up!Cheers!
I am curious why do you prefer pagerfanta over your own knp-pagination bundle? Is it a matter of taste or pagerfanta is more performance efficient? I personally find knp pagination bundle more flexible and easier to use.
Hey Veselin-D,
Actually, that's mostly a matter of taste. Indeed, most bundles are great and feature-rich, though Pagerfanta might be older that's why Ryan just got to use to it :) Feel free to use any of it that you like more... or know better. Though Pagerfanta seems more active (at least the bundle) lately with their releases.
Cheers!
"Houston: no signs of life"
Start the conversation!