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