Sorting Component¶
Version: 0.6.0
Last Updated: January 3, 2026
Location: src/templates/partials/sort_header.html
The sorting component enables users to sort data by clicking on table column headers. It manages sort state (ascending/descending), visual indicators, and preserves filter parameters across sort changes.
Overview¶
Sorting features:
- Single and multi-column sorting
- Ascending/descending toggles
- Visual sort indicators (arrows, icons)
- URL parameter preservation
- Keyboard accessible
- Works with Django ORM querysets
Basic Usage¶
Simple Sort Link¶
<thead>
<tr>
<th>
<a href="?sort=name" class="sort-link">Name</a>
</th>
<th>
<a href="?sort=-created_at" class="sort-link">Date</a>
</th>
</tr>
</thead>
Sort with Indicator¶
{% if sort_field == 'name' %}
<th class="sortable ascending">Name <i class="bi bi-sort-up"></i></th>
{% elif sort_field == '-name' %}
<th class="sortable descending">Name <i class="bi bi-sort-down"></i></th>
{% else %}
<th class="sortable">
<a href="?sort=name">Name</a>
</th>
{% endif %}
View Implementation¶
ListView with Sorting¶
from django.views.generic import ListView
from tickets.models import Ticket
class TicketListView(ListView):
model = Ticket
template_name = 'tickets/ticket_list.html'
paginate_by = 20
def get_queryset(self):
queryset = Ticket.objects.all()
# Get sort parameter from URL
sort = self.request.GET.get('sort', '-created_at')
# Validate sort parameter (security)
allowed_sorts = ['name', '-name', 'created_at', '-created_at', 'priority', '-priority']
if sort in allowed_sorts:
queryset = queryset.order_by(sort)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['sort'] = self.request.GET.get('sort', '-created_at')
return context
Sort Header Template Tag¶
Custom Template Tag¶
# src/core/templatetags/sort_tags.py
from django import template
from django.utils.html import format_html
register = template.Library()
@register.simple_tag(takes_context=True)
def sort_header(context, field_name, display_name):
request = context['request']
current_sort = request.GET.get('sort')
# Determine next sort direction
if current_sort == field_name:
next_sort = f'-{field_name}' # Toggle to descending
icon = '<i class="bi bi-sort-up"></i>'
elif current_sort == f'-{field_name}':
next_sort = field_name # Toggle to ascending
icon = '<i class="bi bi-sort-down"></i>'
else:
next_sort = field_name # First click
icon = '<i class="bi bi-sort"></i>'
# Build URL with preserved parameters
params = request.GET.copy()
params['sort'] = next_sort
url = f"?{params.urlencode()}"
active_class = 'active' if current_sort in [field_name, f'-{field_name}'] else ''
return format_html(
'<a href="{}" class="sort-link {}">{} {}</a>',
url, active_class, display_name, icon
)
Template Usage¶
{% load sort_tags %}
<thead>
<tr>
<th>{% sort_header 'name' 'Name' %}</th>
<th>{% sort_header 'priority' 'Priority' %}</th>
<th>{% sort_header 'created_at' 'Created' %}</th>
</tr>
</thead>
Sort Indicators¶
CSS Icons¶
<!-- No sort -->
<i class="bi bi-arrow-down-up"></i>
<!-- Ascending (A-Z, 1-9) -->
<i class="bi bi-sort-up"></i>
<!-- Descending (Z-A, 9-1) -->
<i class="bi bi-sort-down"></i>
Custom Styling¶
.sort-link {
color: #495057;
text-decoration: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
&:hover {
color: #0d6efd;
}
&.active {
color: #0d6efd;
font-weight: 600;
}
i {
opacity: 0.6;
}
&.active i {
opacity: 1;
}
}
Parameter Preservation¶
Maintain Filters During Sort¶
# In view
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Get current filters
context['search_query'] = self.request.GET.get('q')
context['status_filter'] = self.request.GET.get('status')
context['sort_field'] = self.request.GET.get('sort')
return context
Template URL Building¶
{% load querystring_tags %}
<a href="?{% querystring sort='name' %}">
Sort by Name
</a>
<!-- Preserves: q=search&status=open while adding sort=name -->
Security¶
Prevent SQL Injection¶
# Always validate sort parameter
ALLOWED_SORTS = {
'name': 'title',
'-name': '-title',
'date': 'created_at',
'-date': '-created_at',
'priority': 'priority',
}
requested_sort = self.request.GET.get('sort')
if requested_sort in ALLOWED_SORTS:
queryset = queryset.order_by(ALLOWED_SORTS[requested_sort])
Testing¶
Sort Functionality Tests¶
from django.test import TestCase, Client
from tickets.models import Ticket
class SortingTest(TestCase):
@classmethod
def setUpTestData(cls):
Ticket.objects.create(title='Zebra', priority='low')
Ticket.objects.create(title='Apple', priority='high')
def test_sort_ascending(self):
response = self.client.get('/tickets/?sort=name')
tickets = list(response.context['tickets'])
self.assertEqual(tickets[0].title, 'Apple')
def test_sort_descending(self):
response = self.client.get('/tickets/?sort=-name')
tickets = list(response.context['tickets'])
self.assertEqual(tickets[0].title, 'Zebra')
def test_sort_preserves_filters(self):
response = self.client.get('/tickets/?q=test&sort=name')
self.assertContains(response, 'q=test')
self.assertContains(response, 'sort=name')
Best Practices¶
✅ Always validate sort parameters
✅ Preserve other query parameters when sorting
✅ Show visual indicator of current sort
✅ Support multiple sort directions (asc/desc)
✅ Use semantic column headers (<th>)
✅ Add ARIA labels for screen readers
❌ Don't allow arbitrary sort parameters
❌ Don't lose filter state when sorting
❌ Don't sort on client-side only (sort on server)
Performance¶
Database Indexes¶
class Ticket(models.Model):
title = models.CharField(max_length=255, db_index=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
priority = models.CharField(max_length=20, db_index=True)
Add database indexes to frequently sorted columns for better performance.