How to make simple paginator in Django and HTMx. Adding fitering and sorting feature. pr. 2

Clock
08.04.2025
Clock
15.04.2025
Clock
10 minutes
An eye
26
Hearts
0
Connected dots
0
Connected dots
0
Connected dots
0

Introduction

Any paginator is as good as its filters. In this article, I will show how you can "organically" integrate any filters and sorting into your paginator. This paginator will be written using HTMx. Which is going to communicate with the API of our site, configured using the Django REST framework.
The filters and sorters that we will add to the paginator will be static. What do I mean? By static filters and sorters, I mean those filters that require you to click the sort button to apply. That is, first select the necessary options, then click the corresponding "sort" button.
I could make it dynamic, but this would require writing additional JS code and greatly complicating the implementation of the paginator, and this would no longer be an article about "adding filters to a simple paginator." That's not what the article is about.
What the filters will look like on the page

Frontend part

Our filters and sorters are located in one place and are framed by the form tag. There is no fundamental difference between them. At least, they will be formatted the same way on the client side.
Let's create a Django template where our filters and sorters will be rendered. I called it paginator-filters.html and placed it in the parts/ directory. Now let's figure out what is written there. Here is the full template code:
{% load static %}
{% load i18n %}

<form id="filters-sorters-form" class="flex flex-col gap-[4px]">
<input class="hidden" name="page" value="1"/>
<div class="menu menu-horizontal bg-base-200 rounded-box self-center items-center w-full">
<li class="menu-title flex-auto w-full">{% trans 'Фильтры' %}</li>
<li>
<details close>
<summary hx-get="api/categories/" hx-trigger="load" hx-target="#categories-container">{% trans 'Категории' %}</summary>
<ul id="categories-container" class="max-h-[300px] z-[1] overflow-y-scroll w-max">
</ul>
</details>
</li>
<li>
<details close>
<summary hx-get="api/periods/" hx-trigger="load" hx-target="#periods-container">{% trans 'Периоды' %}</summary>
<ul id="periods-container" class="max-h-[300px] z-[1] overflow-y-scroll w-max">
</ul>
</details>
</li>
<li>
<details close>
<summary hx-get="api/epochs/" hx-trigger="load" hx-target="#epochs-container">{% trans 'Эпохи' %}</summary>
<ul id="epochs-container" class="max-h-[300px] z-[1] overflow-y-scroll w-max">
</ul>
</details>
</li>
<li>
<details close>
<summary hx-get="api/figures/" hx-trigger="load" hx-target="#figures-container">{% trans 'Люди' %}</summary>
<ul id="figures-container" class="max-h-[300px] z-[1] overflow-y-scroll w-max">
</ul>
</details>
</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if with_pdf %}
<input id="with-pdf" type="checkbox" name="with_pdf" checked="checked">
{% else %}
<input id="with-pdf" type="checkbox" name="with_pdf">
{% endif %}
<label for="with-pdf">{% trans 'Есть PDF' %}</label>
</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if with_audio %}
<input id="with-audio" type="checkbox" name="with_audio" checked="checked">
{% else %}
<input id="with-audio" type="checkbox" name="with_audio">
{% endif %}
<label for="with-audio">{% trans 'Есть подкаст' %}</label>
</li>
</div>
<div class="flex-auto"></div>
<div class="menu menu-horizontal bg-base-200 rounded-box self-center items-center w-full">
<li class="menu-title flex-auto w-full">{% trans 'Сортировка' %}</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if alphabetic_sort %}
<input id="alphabetic-sort" type="checkbox" name="alphabetic_sort" checked="checked">
{% else %}
<input id="alphabetic-sort" type="checkbox" name="alphabetic_sort">
{% endif %}
<label for="alphabetic-sort">{% trans 'A-Z Сотрировка' %}</label>
</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if last_pub_sort %}
<input id="last-pub-sort" type="checkbox" name="last_pub_sort" checked="checked">
{% else %}
<input id="last-pub-sort" type="checkbox" name="last_pub_sort">
{% endif %}
<label for="last-pub-sort">{% trans 'По дате публикации, c первых' %}</label>
</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if last_upd_sort %}
<input id="last-upd-sort" type="checkbox" name="last_upd_sort" checked="checked">
{% else %}
<input id="last-upd-sort" type="checkbox" name="last_upd_sort">
{% endif %}
<label for="last-upd-sort">{% trans 'По дате обновления, с первых' %}</label>
</li>
</div>
<button
type="submit"
for="filters-sorters-form"
class="btn">
{% trans 'Фильтровать' %}
</button>
</form>
<a href="/{{LANGUAGE_CODE}}/"><button class="btn btn-error w-full">
{% trans 'Очистить' %}
</button></a>
There are two buttons in this form. One sends a request with parameters, the other sends a request without parameters, that is, clears the filters.
...
<button
type="submit"
for="filters-sorters-form"
class="btn">
{% trans 'Фильтровать' %}
</button>
</form>
<a href="/{{LANGUAGE_CODE}}/"><button class="btn btn-error w-full">
{% trans 'Очистить' %}
</button></a>
The one that is highlighted sends a request with parameters, that is, filters the output
We have figured out how and where requests for filtering and sorting are sent from. Now let's figure out what and how we send to the server.
At the very beginning, there is a hidden input field that contains the page number. It is always equal to 1, by the way. Why start filtering from the first page, and not, say, from the current one? In general, this can be done, it will just lead to an overcomplicating the code on the server. For example, we have access to only 5 pagination pages, based on the filtering results, and we are now on the 6th.
After the page number, the form is divided into a part of filters and a part of sorters. Let's first look at the sorters:
<div class="menu menu-horizontal bg-base-200 rounded-box self-center items-center w-full">
<li class="menu-title flex-auto w-full">{% trans 'Сортировка' %}</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if alphabetic_sort %}
<input id="alphabetic-sort" type="checkbox" name="alphabetic_sort" checked="checked">
{% else %}
<input id="alphabetic-sort" type="checkbox" name="alphabetic_sort">
{% endif %}
<label for="alphabetic-sort">{% trans 'A-Z Сотрировка' %}</label>
</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if last_pub_sort %}
<input id="last-pub-sort" type="checkbox" name="last_pub_sort" checked="checked">
{% else %}
<input id="last-pub-sort" type="checkbox" name="last_pub_sort">
{% endif %}
<label for="last-pub-sort">{% trans 'По дате публикации, c первых' %}</label>
</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if last_upd_sort %}
<input id="last-upd-sort" type="checkbox" name="last_upd_sort" checked="checked">
{% else %}
<input id="last-upd-sort" type="checkbox" name="last_upd_sort">
{% endif %}
<label for="last-upd-sort">{% trans 'По дате обновления, с первых' %}</label>
</li>
</div>
The first thing I want to note is the template variables, such as alphabetic_sort, last_pub_sort and last_upd_sort. They are defined and returned by the server. And if they are defined, the input fields will already be checked. Also in the input element I define the name of the parameter being sent, it will also be the name of the variable in the template.
That's all about the sorters, let's move on to the filters. In the filter block, you can find two types:
  1. Simple, these are those that work exactly the same as sorters. The same markup.
  2. Complex, these are those that will have to make a preliminary request to the server to find out which filter elements to display.
<li>
<details close>
<summary hx-get="api/epochs/" hx-trigger="load" hx-target="#epochs-container">{% trans 'Эпохи' %}</summary>
<ul id="epochs-container" class="max-h-[300px] z-[1] overflow-y-scroll w-max">
</ul>
</details>
</li>
An example of a complex filter.
I make a GET request to the address "api/epochs/" using the hx-get attribute. The hx-tirgger attribute specifies when this request should be made. And the target element is specified into which the response from the server will be loaded, this target element is specified using the hx-target attribute.
All names and their modifiers for triggers for sending requests in HTMx can be found here, on the official website, or see here.
As you can see, I access the API of my site to get all possible filtering options. To render them, you will need to write a corresponding template, let's call it paginator-filter-items.html and put it in the parts/ directory.
{% load static %}
{% load i18n %}

{% for item in queryset %}
<li name="filter" class="filter flex flex-row gap-[4px] items-center">
{% if key_filters %}
{% if item.slug not in key_filters %}
<input id="{{parameter}}-{{item.slug}}" type="checkbox" name="{{parameter}}" value="{{item.slug}}" class=""/>
{% else %}
<input id="{{parameter}}-{{item.slug}}" type="checkbox" name="{{parameter}}" value="{{item.slug}}" class="" checked="checked"/>
{% endif %}
{% else %}
<input id="{{parameter}}-{{item.slug}}" type="checkbox" name="{{parameter}}" value="{{item.slug}}" class=""/>
{% endif %}
<label for="{{parameter}}-{{item.slug}}">{{item.name}}</label>
</li>
{% endfor %}
This template will render all available filter elements and mark active input fields.
I want to note. By all available filtering elements I mean django models and their specific records in the DB. By which you can filter articles.
We are almost done with the frontend, we just need to update the paginator-buttons.html file. In each hx-post attribute, at the end, add the following template variable, base_url. This is done so that filtering and sorting are preserved when moving between pages. Here is the updated file:
{% load i18n %}
{% load static %}

<form id="pagination_buttons" class="join self-center">
{% csrf_token %}
{% if is_articles_prev %}
<button
class="join-item btn"
hx-post="?page=1{{base_url}}"
hx-target="#search-results"
hx-replace-url="true"
value="1"
name="page"
>
{% trans '<<' %}
</button>
<button
class="join-item btn"
hx-post="?page={{articles_prev_page}}{{base_url}}"
hx-target="#search-results"
hx-replace-url="true"
value="{{articles_prev_page}}"
name="page"
>
{% trans '<' %}
</button>
{% else %}
<button class="join-item btn btn-disabled">{% trans '<<' %}</button>
<button class="join-item btn btn-disabled">{% trans '<' %}</button>
{% endif %}

<button class="join-item btn btn-disabled">{{articles_start_page}}/{{articles_length}}</button>

{% if is_articles_next%}
<button
class="join-item btn"
hx-post="?page={{articles_next_page}}{{base_url}}"
hx-target="#pagination_buttons"
hx-swap="outerHTML"
hx-replace-url="true"
value="{{articles_next_page}}"
name="page"
>
{% trans '>' %}
</button>
<button
class="join-item btn"
hx-post="?page={{articles_length}}{{base_url}}"
hx-target="#search-results"
hx-replace-url="true"
value="{{articles_length}}"
name="page"
>
{% trans '>>' %}
</button>
{% else %}
<button class="join-item btn btn-disabled">{% trans '>' %}</button>
<button class="join-item btn btn-disabled">{% trans '>>' %}</button>
{% endif %}
</form>
The lines where changes have occurred are marked in yellow.

Backend part

The whole point of the client part of filters and sorters is to build the corresponding URL and send it to the server, where the server then figures out what and how to return to the user.
All this will happen in the previously written HomeView class based view, in the Frontend/views.py file. Which we connected in Frontend/urls.py.
from django.urls import path
from .views import SideView, HomeView

urlpatterns = [
path('', HomeView.as_view(), name='domashnyaya'),
path('o-proekte/', SideView.as_view(template_name="o-proekte.html"), name='o-proekte'),
]
Now let's look at how I changed the HomeView class:
import urllib
from django.shortcuts import render
from django.http import Http404
from django.core.paginator import Paginator
from django.utils.translation import gettext as _
from django.utils.translation import get_language
from django.views.generic import TemplateView
from Backend.models import *



class HomeView(TemplateView):

template_name = 'domashnyaya.html'
page_size = 2

def get_context_data(self, **kwargs):
context = super(HomeView, self).get_context_data(**kwargs)

return context
def fetch(self, request, context, page_num, *args, **kwargs):
# Update base URL for paginator
referer_url = request.get_full_path()
url = urllib.parse.urlparse(referer_url)
query = urllib.parse.parse_qs(url.query)
# Filter out needed objects
# Filter out other languages versions, and time published, base filtering and sorting
articles_by_lang = Article.objects.filter(lang_type=get_language())
articles = articles_by_lang.order_by("-time_published")
for key, values in query.items():
match key:
case "category":
categories = Category.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(categories__in=categories).distinct()
case "period":
periods = Period.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(periods__in=periods).distinct()
case "epoch":
epochs = Epoch.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(epoch__in=epochs).distinct()
case "figure":
figures = Figure.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(figures__in=figures).distinct()
case "with_pdf":
articles = articles.filter(is_pdf_version=True)
case "with_audio":
articles = articles.filter(is_audio_version=True)
case "alphabetic_sort":
articles = articles.order_by("header")
case "last_pub_sort":
articles = articles.order_by("time_published")
case "last_upd_sort":
articles = articles.order_by("-time_updated")
# Create a paginator, build-in Django one
paginator = Paginator(articles, self.page_size)
pagination_length = paginator.num_pages
# Check if page out of range
if page_num > pagination_length or page_num <= 0:
raise Http404()
# Paginate to page
page = paginator.page(page_num)
is_prev = page.has_previous()
is_next = page.has_next()
page_articles = page.object_list

if len(query) <= 0:
base_url = ""
else:
base_url = "&"
for key, values in query.items():
if key != 'page':
for value in values:
base_url += f"{key}={value}&"
context.update({key: True})
base_url = base_url[:-1]

# Update context
context.update({
'base_url': base_url,
'articles': page_articles,
'articles_start_page': page_num,
'articles_next_page': page_num + 1,
'articles_prev_page': page_num - 1,
'articles_length': pagination_length,
'is_articles_next': is_next,
'is_articles_prev': is_prev
})

def post(self, request, *args, **kwargs):
context = super(HomeView, self).get_context_data(**kwargs)
page_num = int(request.POST.get('page', 1))
self.fetch(request, context, page_num, *args, **kwargs)
return render(request, 'parts/paginator/article-simple-paginator.html', context=context)

def get(self, request, *args, **kwargs):
context = super(HomeView, self).get_context_data(**kwargs)
page_num = int(request.GET.get('page', 1))
self.fetch(request, context, page_num, *args, **kwargs)
return render(request, self.template_name, context=context)
In this file I added the creation of a base URL address, which is attached to the hx-post attribute, in the pagination buttons. It works quite simply. It takes the request address, breaks it into key-value pairs and connects them back, but only without specifying the page number.
Oh, and I almost forgot. I also added the logic for filtering and sorting articles. It is at the very beginning. Let's go through it in order:
referer_url = request.get_full_path()
url = urllib.parse.urlparse(referer_url)
query = urllib.parse.parse_qs(url.query)
Here, I extract the query itself using variables in the URL.
articles_by_lang = Article.objects.filter(lang_type=get_language())
articles = articles_by_lang.order_by("-time_published")
Here, basic filtering and sorting by publication date occurs (the newest articles are displayed first).
Afterward, I perform direct filtering and sorting by the parameters that were sent via the URL.
for key, values in query.items():
match key:
case "category":
categories = Category.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(categories__in=categories).distinct()
case "period":
periods = Period.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(periods__in=periods).distinct()
case "epoch":
epochs = Epoch.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(epoch__in=epochs).distinct()
case "figure":
figures = Figure.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(figures__in=figures).distinct()
case "with_pdf":
articles = articles.filter(is_pdf_version=True)
case "with_audio":
articles = articles.filter(is_audio_version=True)
case "alphabetic_sort":
articles = articles.order_by("header")
case "last_pub_sort":
articles = articles.order_by("time_published")
case "last_upd_sort":
articles = articles.order_by("-time_updated")
This sorting works on the "pipe" principle. That is, each subsequent filter works with the output data of the previous filter. Thus, only those articles that have passed through all filters remain.
When filtering the Figure, Epoch, Period, and Category model objects, I use parameters with the __in suffix. This means that I want to find all the records in the database in the specified list. It may return duplicate records, so after the filter method, I add the distinct method. This will remove all duplicates.
The match operator is only available in Python 3.10 and above. So if you're using a lower version, then alas ┐( ̄~ ̄)┌, switch to if-else.
Another class to consider is the FiltersView. This is a class based view that works and returns html pages with a list of all available filters for records in the Figure, Epoch, Period, and Category models.
This class based view is not required for development and implementation. But it is very convenient if the model you want to paginate by has fields of relationships between models, such as ManyToMany or ForeignKey.
Here is the code for its FiltersView class:
class FiltersView(generics.ListAPIView):
renderer_classes = [TemplateHTMLRenderer]
template_name = 'parts/paginator/paginator-filter-items.html'
parameter = ""

def get(self, request):
referer_url = request.META['HTTP_REFERER']
url = urllib.parse.urlparse(referer_url)
query = urllib.parse.parse_qs(url.query)
context = {}

if len(query) > 0:
values_to_update = []
for key, values in query.items():
for value in values:
if key == 'page':
pass
elif key != self.parameter:
pass
else:
values_to_update.append(value)
context.update({f'key_filters': values_to_update})

queryset = self.get_queryset().filter(lang_type=get_language())

context.update({
'queryset': queryset,
'parameter': self.parameter,
})

return Response(context)
The main purpose of this view is to "filter filters" by language and mark them in the template if needed (i.e. mark them checked if they are already in use). It also needs to be connected in Backend/urls.py:
from django.urls import path, include
from rest_framework import routers
from Backend import views, models


urlpatterns = [
path('api/categories/', views.FiltersView.as_view(parameter="category", serializer_class=views.CategorySerializer, queryset = models.Category.objects.all())),
path('api/epochs/', views.FiltersView.as_view(parameter="epoch", serializer_class=views.EpochSerializer, queryset = models.Epoch.objects.all())),
path('api/periods/', views.FiltersView.as_view(parameter="period", serializer_class=views.PeriodSerializer, queryset = models.Period.objects.all())),
path('api/figures/', views.FiltersView.as_view(parameter="figure", serializer_class=views.FigureSerializer, queryset = models.Figure.objects.all())),
]

When connecting FiltersView, you need to specify the following parameters:
  1. Parameter name (it will be used in the URL)
  2. Serializer class
  3. List of objects that the FilterView class will work with

Conclusion

Here's what I'd like to say about filters on paginator pages. It's not difficult, if you don't complicate it and reinvent the wheel. As you may have noticed, I used HTMx to a minimum, and almost didn't use REST API, because it was not required. We are creating a simple paginator, not a complex-universal uber-duper ultimate search. Cheers.


Comments

(0)

captcha
Send
It's empty now. Be the first (o゚v゚)ノ

Other

Similar articles


About duplicated and Not-canonical pages while implementing paginator

Clock
02.03.2025
An eye
63
Hearts
0
Connected dots
0
Connected dots
0
Connected dots
0
In this article, I will use my site as an example to show what duplicate (non-canonical) content is that appeared in Google Search Console as a result of implementing a …

How to make More button. Using Django, REST API, HTMx

Clock
01.04.2025
An eye
52
Hearts
0
Connected dots
0
Connected dots
0
Connected dots
0
In this article I will describe a way how you can implement asynchronous loading of content (articles) using the "More" button, which is made using Django, REST API, HTMx and …

How to make simple paginator in Django and HTMx pr. 1

Clock
02.04.2025
An eye
52
Hearts
0
Connected dots
0
Connected dots
0
Connected dots
0
In this article, I will describe how to create a paginator using Django and the HTMx library. And why it was so easy compared to the paginator on my site. …

How to make simple paginator in Django and HTMx. Adding fitering and sorting feature. pr. 2

Clock
08.04.2025
An eye
26
Hearts
0
Connected dots
0
Connected dots
0
Connected dots
0
In this article I will describe the process and main code blocks to add sorting and filtering feature to a paginator. This paginator is written in Django using HTMx.

How to add the feedback form using Django and HTMx

Clock
11.04.2025
An eye
48
Hearts
0
Connected dots
0
Connected dots
0
Connected dots
0
In this article, I will describe the way to add on your Django website feedback form using only HTMx and a little bit of DaisyUI as a UI library. Everything …

How to customize yourown 404 and 500 pages in Django

Clock
12.04.2025
An eye
49
Hearts
0
Connected dots
0
Connected dots
0
Connected dots
0
In this article, I will describe the process of customizing pages such as 404 and 500. I will show two main ways to do this and how you can quickly …

Used termins


Related questions