Pagination & Column Sorting
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 SubscribeWelcome to Day 13! We're going to tap the breaks on Stimulus and Turbo and only work with Symfony and Twig today. Our goal is to add pagination and column sorting to this list.
Adding Pagination
I like to add pagination with Pagerfanta. I love this library, though I do get a bit lost in its documentation. But hey: it's open source, if you're not happy, go fix it!
To use Pagerfanta, we'll install three libraries:
composer require babdev/pagerfanta-bundle pagerfanta/doctrine-orm-adapter pagerfanta/twig
Cool beans! Let's get the PHP side working first. Open src/Controller/MainController.php. The current page will be stored on the URL as ?page=1 or ?page=2, so we need to read that page query parameter. I'll do that with a cool newish #[MapQueryParameter] attribute. And actually, before... I was doing too much work. If your query parameter matches your argument name, you don't need to specify it there. So, I'll remove it on those two. It is different for searchPlanet: a parameter we'll use later.
Anyway, this will read the ?page= and we'll default it to 1. Oh, and the order of these doesn't matter:
| // ... 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 | |
| } |
Below, copy the $voyageRepository->findBySearch() line, and replace it with a Pager object: $pager equals 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 | |
| } | |
| } |
The first argument is an adapter: new QueryAdapter then paste in the code from before. But, that's not quite right: this method returns an array of voyages:
| // ... 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 | |
| } |
but we now need a QueryBuilder. Fortunately, I already set things up so that we can get this same result, but as a QueryBuilder via: 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; | |
| } | |
| } |
Paste that method name in.
The next argument is the current page - $page - then max per page. How about 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 | |
| } | |
| } |
Pass $pager to the template as the voyages variable:
| // ... 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 | |
| ]); | |
| } | |
| } |
That... should just work because we can loop over $pager to get the voyages.
Rendering the Pagination Links
Next up, in homepage.html.twig, we need pagination links! Down at the bottom, I already have a spot for this with hardcoded previous and next links:
| // ... 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 %} |
The way you're supposed to render Pagerfanta links is by saying {{ pagerfanta() }} and then passing 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 |
When we try this - let me clear my search out - the pagination looks awful... but it is working! As we click, the results are changing.
So... how can we change these pagination links from "blah" to "ah"? There is a built-in Tailwind template that you can tell Pagerfanta to use. That involves creating a babdev_pagerfanta.yaml file and a bit of configuration. I haven't used this before - so let me know how it goes!
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'
Because... I'm going to be stubborn. I want to just have previous & next buttons... and I want to style them exactly like this. So let's go rogue!
The first thing we need to do is render these links conditionally, only if there is a previous page. To do that, say if voyages.hasPreviousPage, then render. And, if we have a next page, render that:
| // ... 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 %} |
For the URLs, use a helper called pagerfanta_page_url(). Pass it the pager, voyages, then which page we want to go to: voyages.previousPage. Copy that, then repeat it below with 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 %} |
Lovely! Let's give that a try. Refresh. Love it! The previous page is missing, we click next, and it's there. Click next again. Page 3 is the last one. We got it!
For extra credit, let's even print the current page. Add a div... then print voyages.currentPage, a slash and 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 %} |
Good job, AI!
And... there we go. Page 1 of 3. Page 2 of 3.
Column Sorting
What about column sorting? I want to be able to click each column to sort by that. For this, we need two new query parameters. A sort column name and sortDirection. Back to PHP! Add #[MapQueryParameter] on a string argument called $sort. Default it to leaveAt. That's the property name for this departing column. Then, do #[MapQueryParameter] again to add a string $sortDirection that defaults to ascending:
| // ... 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 | |
| } | |
| } |
Inside the method, I'll paste 2 boring lines that validate that sort is a real column:
| // ... 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 | |
| } | |
| } |
We could probably do the same for $sortDirection, but I'll skip and go to findBySearchQueryBuilder(). This is already set up to expect the sort arguments. So pass $sort and $sortDirection... and it should be happy!
| // ... 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 | |
| } | |
| } |
Finally, we're going to need this info in the template to help render the sort links. Pass sort set to $sort and sortDirection set to $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, | |
| ]); | |
| } | |
| } |
Adding the Column Sorting Links
The most tedious part is transforming each th into the proper sort link. Add an a tag and break it onto multiple lines. Set the href to this page - the homepage - with an extra sort set to purpose: the name of this column. For sortDirection, this is more complex: if sort equals purpose and sortDirection is asc, then we want desc. Otherwise, use asc.
Finally, in addition to the sort and sortDirection query parameters, we need to keep any existing query parameters that might be present - like the search query. And there's a cool way to do this: ... then 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 %} |
Done! Oh, but after the word Purpose, let's add a nice down or up arrow. To help, I'll paste a Twig macro. I don't often use macros... but this will help us figure out the direction, then print the correct SVG: a down arrow, an up arrow, or an up and down arrow:
| // ... 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 |
Down here... use this with {{ _self.sortArrow() }} passing 'purpose', sort and 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 %} |
Phew! Let's repeat all of this for the departing column. Paste, change purpose to leaveAt, the text to Departing... then use leaveAt in the other two spots:
| // ... 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 %} |
So, all pretty boring code, though it was a bit of work to get this set up. Could we have some tools in the Symfony world to make this all easier to build? Probably. That would be a cool thing for someone to work on.
Moment of truth! Refresh. That looks good. And it works great! We can sort by each column... we can paginate. Filtering keeps our page... and keeps the search parameter. It's everything I want! And it's all happening via Ajax! Life is good!
The only hiccup now? That awkward scrolling whenever we do anything. I want this to feel like a standalone app that doesn't jump around. Tomorrow: we'll polish this thanks to 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!