Skip to content

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

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


Resources