Search, Sort & Filter — UI Standard¶
One standard for every list page. If you're adding a new list view or touching an existing one, this doc is the source of truth for sort, filter, and search UX.
The blessed stack¶
| Layer | Component | Where |
|---|---|---|
| View mixin (sort + column filter + per-page) | SortableFilterableListMixin |
apps/core/mixins.py |
| View mixin (HTMX partials) | HtmxResponseMixin |
apps/core/mixins.py |
| Column header tag | {% table_header %} |
apps/core/templatetags/table_tags.py → templates/components/table_header.html |
| Practice scope filter | {% practice_filter %} + MultiPracticeFilterMixin |
apps/core/templatetags/practice_filter_tags.py |
| Dentist scope filter | {% dentist_filter %} + MultiDentistFilterMixin |
apps/core/templatetags/dentist_filter_tags.py |
| Date-range filter | {% period_filter %} + PeriodFilterMixin |
apps/core/templatetags/period_filter_tags.py |
| Single-select form field | SearchSelect widget |
apps/core/widgets.py |
| HTMX / cookie JS utility | HtmxFilter |
static/js/utils/htmx-filter.js |
| Typeahead filter / reset helper (shared by the two search-select components) | TypeaheadList |
static/js/utils/typeahead-list.js |
| Table filter JS (pills, search-select, text search) | table-filter.js |
static/js/components/table-filter.js |
| Form search-select JS | form-search-select.js |
static/js/components/form-search-select.js |
| Per-page select (partial + JS) | per_page_select.html + per-page-select.js |
templates/components/per_page_select.html, static/js/components/per-page-select.js |
| List page skeleton | skeleton_list.html |
templates/skeletons/ |
Best existing examples:
apps/procedures/views.py+apps/procedures/templates/procedures/partials/procedure_list_content.html— the reference implementation. Copy this shape for a new list view.apps/patients/views.py— rich mix ofboolean,choice,fk,searchfilters.
Decision tree — which mechanism do I use?¶
User needs to narrow a list page?
├── By a column value (status, category, amount, name…)
│ → Column filter via {% table_header filterable=True %}
│ + filterable_fields entry in the view
│
├── By a practice, dentist, or date range that also scopes other pages
│ → Global toolbar filter: practice / dentist / period
│ Never duplicate these as column filters.
│
└── A full free-text search across multiple fields
→ Add a 'custom' entry in filterable_fields, handle in get_queryset
with a Q(field_a__icontains=val) | Q(field_b__icontains=val)
(like procedures view does for 'patient').
Do NOT reintroduce a separate ?q= search box above the table.
User needs to sort?
└── Always via column header sort link (single field, asc/desc toggle).
No multi-column sort. No separate sort dropdown.
User needs to pick a single record from a long list *inside a form*?
└── SearchSelect widget (not the column filter — that's for list scoping).
Query-string conventions¶
| Param | Meaning | Notes |
|---|---|---|
sort=<field>&dir=asc\|desc |
Column sort | <field> is a key of sortable_fields, not a DB column |
<field>=<value> |
Column filter | One param per filter; key matches filterable_fields |
page=<n> |
Pagination cursor | Standard Django |
per_page=<n> |
Page size | Must be one of per_page_choices (default [25, 50, 100, 200, 500]) |
practices=1&practices=2 or practices=none |
Practice scope | Repeat-key encoding; none = explicitly no practice |
dentists=… |
Dentist scope | Same shape as practices |
preset=<key> / start_date=YYYY-MM-DD&end_date=YYYY-MM-DD / period_category=… |
Period scope | Exactly one of preset or custom range |
scope=<code>&period=YYYY-MM |
Finance scope + month | Finance module only |
Do not invent new single-letter params (no ?q=, ?s=, ?f=…). If you need
something new, put it under a column filter.
Params that are "filter-managed" (and therefore excluded from URL preservation
in sort/filter links) are centralised in HtmxResponseMixin.excluded_params:
practice, practices, dentist, dentists, preset, start_date,
end_date, page, period_category. Update that set — not individual
templates — if you add a new toolbar filter.
Cookies¶
Scope filters persist via cookies so navigation keeps the user's context:
| Cookie | Written by | Read by |
|---|---|---|
aletheia_practices |
practice-filter.js | MultiPracticeFilterMixin, dentist-filter.js |
aletheia_dentists |
dentist-filter.js | MultiDentistFilterMixin |
aletheia_period, aletheia_period_start, aletheia_period_end, aletheia_period_category |
period-filter.js | PeriodFilterMixin |
aletheia_finance_scope, aletheia_finance_period |
finance-filter.js | FinanceFilterMixin |
All values are URL-encoded. Column filters and sort state do not get cookies — they live in the URL only, so bookmarks and links carry them.
Display-mode rules for column filters¶
SortableFilterableListMixin auto-picks a display mode per column. You rarely
need to override.
type |
Auto display mode | When to override |
|---|---|---|
boolean |
pills (always) |
— |
choice with ≤ 8 options |
pills |
Pass 'display': 'search-select' if options are long strings |
choice with > 8 options |
search-select |
Pass 'display': 'pills' only if you need a very compact, always-visible row |
fk |
pills / search-select by count |
Same as choice |
search |
free-text input (icontains) | — |
custom |
not rendered by the mixin | The view renders its own filter_configs[field] entry |
Threshold is hardcoded at 8 in the mixin; don't duplicate it — use
inject_dynamic_choices() or pass explicit display.
Adding a standard list view — view¶
# apps/myapp/views.py
from django.views.generic import ListView
from apps.core.mixins import (
FeatureRequiredMixin,
HtmxResponseMixin,
SortableFilterableListMixin,
)
from .models import Thing
class ThingListView(
HtmxResponseMixin,
FeatureRequiredMixin,
SortableFilterableListMixin,
ListView,
):
required_feature = 'thing_view'
model = Thing
template_name = 'myapp/thing_list.html'
partial_template_name = 'myapp/partials/thing_list_content.html'
htmx_target = '#list-content'
context_object_name = 'things'
paginate_by = 50
# Keys are the public name used in URLs and in {% table_header %}.
# Values are DB expressions (string or tuple of strings).
sortable_fields = {
'code': 'code',
'name': 'name',
'owner': ('owner__last_name', 'owner__first_name'),
'amount': 'amount',
}
default_sort = 'code'
default_sort_dir = 'asc'
filterable_fields = {
'code': {'type': 'search', 'db_field': 'code'},
'name': {'type': 'search', 'db_field': 'name'},
'status': {
'type': 'choice',
'db_field': 'status',
'choices': Thing.STATUS_CHOICES, # ≤8 → pills, >8 → search-select
},
'active': {
'type': 'boolean',
'db_field': 'is_active',
'choices': [('', 'Tous'), ('1', 'Actif'), ('0', 'Inactif')],
},
'owner': {
'type': 'fk',
'db_field': 'owner_id',
'model': User,
'label_field': 'get_full_name', # or a plain field name
},
# 'custom' = the mixin skips it; your get_queryset handles it.
'search': {'type': 'custom'},
}
def get_queryset(self):
qs = super().get_queryset().select_related('owner')
term = self.request.GET.get('search')
if term:
qs = qs.filter(
Q(code__icontains=term) | Q(name__icontains=term),
)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
# If a filter's choices must be computed at runtime:
cities = Thing.objects.values_list('city', flat=True).distinct().order_by('city')
self.inject_dynamic_choices(ctx, 'city', [(c, c) for c in cities])
# 'custom' filters default to pills with empty choices — override
# to a text-search input. filter_values / active_filters_count are
# already auto-wired because 'search' is in filterable_fields.
ctx['filter_configs']['search'] = {
'type': 'search', 'choices': [], 'display_mode': 'search',
}
return ctx
Canonical mixin inheritance order¶
HtmxResponseMixin, # response channel (partial vs full)
FeatureRequiredMixin, # permission gate
PeriodFilterMixin, # scope: date range (only if needed)
MultiPracticeFilterMixin, # scope: practices (only if needed)
MultiDentistFilterMixin, # scope: dentists (only if needed)
SortableFilterableListMixin, # per-column sort + filter
ListView,
Behaviourally these mixins hook at different points (dispatch vs
get_queryset vs get_context_data) so order barely changes outcomes
— this is for readability and consistency. Stick to this sequence in
new list views; existing outliers should be nudged to it when touched.
Things to remember¶
- The mixin already handles
boolean,choice,fk,search. Only go'custom'when the filter spans multiple DB fields (multi-column text search) or needs special logic. - For totals/KPIs above a paginated list, compute them from the filtered
queryset in
get_context_data, not fromthings|length(that's page size). Useself.get_queryset()orpaginator.count.
Adding a standard list view — templates¶
Full page (myapp/thing_list.html):
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Things" %} - Aletheia{% endblock %}
{# Scope filters (optional — remove the ones you don't need) #}
{% block page_filters %}
{% load practice_filter_tags period_filter_tags %}
{% practice_filter selected_ids=selected_practice_ids htmx_target="#list-content" %}
{% period_filter htmx_target="#list-content" %}
{% endblock %}
{% block content %}
<div id="list-content">
{% include 'myapp/partials/thing_list_content.html' %}
</div>
{% endblock %}
Partial (myapp/partials/thing_list_content.html) — returned on HTMX
requests, included inside the full page on first load:
{% load i18n table_tags %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-0"><i class="bi bi-collection"></i> {% trans "Things" %}</h2>
<small class="text-muted">
{{ page_obj.paginator.count }} {% trans "thing" %}{{ page_obj.paginator.count|pluralize:"s" }}
</small>
</div>
<a href="{% url 'myapp:thing_create' %}" class="btn btn-action-primary">
<i class="bi bi-plus-circle"></i> {% trans "Ajouter" %}
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body">
{% if things or active_filters_count %}
{% include 'components/pagination_top.html' %}
<div class="table-responsive">
<table class="table table-hover table-sm table-dense">
<thead class="table-light">
<tr>
{% table_header 'code' _("Code") sortable=True filterable=True %}
{% table_header 'name' _("Nom") sortable=True filterable=True %}
{% table_header 'status' _("Statut") sortable=True filterable=True %}
{% table_header 'owner' _("Responsable") sortable=True filterable=True %}
{% table_header 'amount' _("Montant") sortable=True css_class="text-end" %}
</tr>
</thead>
<tbody>
{% for thing in things %}
<tr>
<td><strong>{{ thing.code }}</strong></td>
<td>{{ thing.name }}</td>
<td><span class="badge badge-status-{{ thing.status }}">{{ thing.get_status_display }}</span></td>
<td>{{ thing.owner.get_full_name|default:"-" }}</td>
<td class="text-end">{{ thing.amount }}</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center py-5 text-muted">
<i class="bi bi-search"></i> {% trans "Aucun résultat." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include 'components/pagination.html' %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-collection" style="font-size: 4rem;"></i>
<p class="mt-3">{% trans "Aucun élément." %}</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% table_header %} signature¶
{% table_header <field_name> <display_text> sortable=<bool> filterable=<bool>
css_class="<extra classes>" x_show="<alpine expr>" %}
<field_name>must match a key insortable_fieldsforsortable=Trueto take effect, and a key infilterable_fieldsforfilterable=True.sortable=Trueon a non-sortable field is silently ignored — the mixin decides.css_classgoes on the<th>; common values:text-end,text-center.x_showlets the column participate in an Alpine visibility toggle (see gotcha inCLAUDE.md: pass an inline expression, not a bare getter).
Recipes¶
Sorting by aggregate / annotated columns¶
When a column shows a computed aggregate (e.g. count of related rows) and
users should be able to sort by it, annotate the queryset before the
mixin's ordering runs. Simplest way: set self.queryset inside
get_queryset so super() returns the annotated base, then let the mixin
apply filters and get_ordering() sort on the alias.
def get_queryset(self):
active_inst = Q(instruments__effective_to__isnull=True)
self.queryset = Entity.objects.filter(lifecycle_status='active').annotate(
num_instruments=Count('instruments', filter=active_inst),
num_equity=Count(
'instruments',
filter=active_inst & Q(instruments__instrument_nature=Instrument.NATURE_EQUITY),
),
)
return super().get_queryset()
sortable_fields = {
'num_instruments': 'num_instruments', # DB expression = annotation alias
'num_equity': 'num_equity',
}
sortable_fields values are DB expressions, so the annotation alias slots
in directly — no extra plumbing.
If a value is not SQL-derivable (e.g. it comes from a Python-side
movement replay), leave it out of sortable_fields rather than faking sort
support. Drop a one-line comment saying why, next to the dict. See
apps/register/views.py:RegisterListView (num_holders) for an example.
Custom multi-field search on a column¶
When a free-text filter needs to span several DB fields (e.g. a "name"
filter that searches first_name, last_name, a related entity's code/name,
and a related dentist's name), declare it in filterable_fields with
type: 'custom' and handle the Q() in get_queryset. The mixin then
auto-wires filter_values and active_filters_count for you — only the
display mode needs an override so the dropdown renders a text input
instead of pills.
filterable_fields = {
'name': {'type': 'custom'}, # multi-field search — see get_queryset
'type': {...},
}
def get_queryset(self):
self.queryset = Holder.objects.select_related('entity', 'dentist')
qs = super().get_queryset() # applies non-custom filters
search = self.request.GET.get('name', '').strip()
if search:
qs = qs.filter(
Q(first_name__icontains=search)
| Q(last_name__icontains=search)
| Q(entity__code__icontains=search)
| Q(entity__name__icontains=search)
| Q(dentist__first_name__icontains=search)
| Q(dentist__last_name__icontains=search)
)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
# Override the mixin's default for 'custom' (pills + empty choices).
ctx['filter_configs']['name'] = {
'type': 'search', 'choices': [], 'display_mode': 'search',
}
return ctx
Reference: apps/register/views.py:HolderListView.
Don't ship an older variant where the key is omitted from
filterable_fields and instead injected via
ctx['filter_values'][key] = … — that path bypasses the mixin's
active_filters_count, leaving the filter-count badge under-reporting.
The declared-as-'custom' form above is the canonical shape.
Totals / KPIs that reflect the active filters¶
When a list page shows KPI cards above the table, they must track the current filter set — not the page — or users will see totals collapse as they paginate or apply a filter.
- Put the KPI row inside the partial, not the full-page wrapper, so HTMX swaps refresh KPIs along with the table.
- Compute totals from
self.object_list(the full filtered queryset, pre-pagination) inget_context_data, not from the paginated context variable (things,entities, …).
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
# SQL-derivable totals: aggregate, don't materialise.
totals = self.object_list.aggregate(
total_instruments=Sum('num_instruments'),
total_debt=Sum('num_debt'),
)
ctx['total_entities'] = self.object_list.count()
ctx['total_instruments'] = totals['total_instruments'] or 0
ctx['total_debt'] = totals['total_debt'] or 0
# Python-side totals (e.g. movement replay): materialise, then sum.
entities_all = list(self.object_list)
ctx['total_holders'] = sum(holder_count(e) for e in entities_all)
return ctx
Materialise the list only when you actually need per-row Python to build the total; otherwise stay in SQL.
Global toolbar filters¶
Use the scope filters when the filter applies across many pages (and should persist as the user navigates):
class PaymentListView(
HtmxResponseMixin,
PeriodFilterMixin,
MultiPracticeFilterMixin,
MultiDentistFilterMixin,
SortableFilterableListMixin,
ListView,
):
practice_filter_param = 'practices'
practice_filter_field = 'practice'
dentist_filter_param = 'dentists'
dentist_filter_field = 'dentist'
date_filter_field = 'payment_date'
default_preset = 'current_month'
...
Then in the full-page template:
{% block page_filters %}
{% load practice_filter_tags dentist_filter_tags period_filter_tags %}
{% practice_filter selected_ids=selected_practice_ids
htmx_target="#list-content"
practice_counts=practice_counts %}
{% dentist_filter selected_ids=selected_dentist_ids
htmx_target="#list-content"
dentist_counts=dentist_counts %}
{% period_filter htmx_target="#list-content" %}
{% endblock %}
Order in the toolbar: practice → dentist → period. The dentist list
auto-refreshes when practices change (via the dentist-filter:refresh
custom event). Don't re-implement that coordination locally.
SearchSelect form widget¶
For a single-value model picker inside a form (not a list filter):
# apps/myapp/forms.py
from apps.core.widgets import SearchSelect
class ThingForm(forms.ModelForm):
class Meta:
model = Thing
fields = ['owner', 'parent']
widgets = {
'owner': SearchSelect(),
'parent': SearchSelect(disabled_choices=already_linked_parent_ids),
}
- Renders a Bootstrap dropdown with a typeahead input; submits a plain hidden
<select>value. disabled_choicesgreys out options and groups them under a "Déjà rattachés" separator — useful when a field should usually pick a free record.- Reuses the same CSS family as column filter's search-select
(
.filter-search-*). When styling, edit there.
Don't reach for SearchSelect to filter a list. For list filtering, rely on
table_header and let the mixin auto-pick search-select when the cardinality
warrants it.
Why two search-select components (and what they share)¶
The column filter and the form widget look identical but do different work:
| Axis | Column filter (data-filter-search-select) |
Form widget (data-form-search-select) |
|---|---|---|
| On pick | Sets a URL param, navigates via HTMX swap | Writes value to a hidden <select>, closes dropdown, no nav |
| Lives inside | A column-header Bootstrap dropdown | A form field (any page) |
| Namespace | data-filter-* |
data-form-* |
| ARIA roles | None | listbox / option / aria-selected |
| Disabled items + "Déjà rattachés" separator | No | Yes |
| Reset list visibility on reopen | No (preserves typed query) | Yes (reopens empty) |
What they share by design:
- CSS: one set of
.filter-search-*classes styles both (seestatic/css/base.css). - Typeahead + reset logic: factored into
window.TypeaheadList(static/js/utils/typeahead-list.js). Both components callTypeaheadList.filter(listEl, query, {valueAttr, disabledAttr?, separator?})for the text match andTypeaheadList.reset(listEl, {separator?})for the open-fresh reset. The sentinel rule — items with no value (Tous/— Sélectionner —) stay visible during filtering — lives there once.
If you're adding a fourth typeahead-list-like surface, prefer adding a thin
component module that calls TypeaheadList over copying the filter loop.
Known deviations (intentional or pending)¶
- Finance module uses its own
FinanceFilterMixinwith ascope + YYYY-MM periodpair. This pre-dates the generic period filter and is tolerated until scope semantics are generalised; do not copy this pattern into new modules.
References¶
- Mixins:
apps/core/mixins.py(HtmxResponseMixin,SortableFilterableListMixin,MultiPracticeFilterMixin,MultiDentistFilterMixin,PeriodFilterMixin,FinanceFilterMixin). - Template tag:
apps/core/templatetags/table_tags.py, rendered viatemplates/components/table_header.html. - JS:
static/js/components/table-filter.js,static/js/utils/htmx-filter.js. - CSS:
static/css/base.css(.filter-*,.column-filter-*,.sort-*). - Skeleton:
templates/skeletons/skeleton_list.html. - Reference view:
apps/procedures/views.py+apps/procedures/templates/procedures/partials/procedure_list_content.html.