Buy Access to Course
13.

Pagination & Column Sorting

|

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

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

39 lines | src/Controller/MainController.php
// ... lines 1 - 12
use Symfony\Component\Routing\Annotation\Route;
class MainController extends AbstractController
{
#[Route('/', 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():

39 lines | src/Controller/MainController.php
// ... 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:

62 lines | src/Repository/VoyageRepository.php
// ... 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:

62 lines | src/Repository/VoyageRepository.php
// ... 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?

39 lines | src/Controller/MainController.php
// ... 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:

39 lines | src/Controller/MainController.php
// ... 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.

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:

90 lines | templates/main/homepage.html.twig
// ... 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:

91 lines | templates/main/homepage.html.twig
// ... 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:

94 lines | templates/main/homepage.html.twig
// ... 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:

97 lines | templates/main/homepage.html.twig
// ... 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:

97 lines | templates/main/homepage.html.twig
// ... 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:

45 lines | src/Controller/MainController.php
// ... lines 1 - 14
class MainController extends AbstractController
{
#[Route('/', 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:

45 lines | src/Controller/MainController.php
// ... lines 1 - 14
class MainController extends AbstractController
{
#[Route('/', 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!

45 lines | src/Controller/MainController.php
// ... lines 1 - 14
class MainController extends AbstractController
{
#[Route('/', 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:

45 lines | src/Controller/MainController.php
// ... lines 1 - 14
class MainController extends AbstractController
{
#[Route('/', 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,
]);
}
}

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:

136 lines | templates/main/homepage.html.twig
// ... 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:

136 lines | templates/main/homepage.html.twig
// ... 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:

136 lines | templates/main/homepage.html.twig
// ... 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:

136 lines | templates/main/homepage.html.twig
// ... 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.