Aller au contenu

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.pytemplates/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 of boolean, choice, fk, search filters.

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 from things|length (that's page size). Use self.get_queryset() or paginator.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 in sortable_fields for sortable=True to take effect, and a key in filterable_fields for filterable=True.
  • sortable=True on a non-sortable field is silently ignored — the mixin decides.
  • css_class goes on the <th>; common values: text-end, text-center.
  • x_show lets the column participate in an Alpine visibility toggle (see gotcha in CLAUDE.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.

  1. Put the KPI row inside the partial, not the full-page wrapper, so HTMX swaps refresh KPIs along with the table.
  2. Compute totals from self.object_list (the full filtered queryset, pre-pagination) in get_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_choices greys 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 (see static/css/base.css).
  • Typeahead + reset logic: factored into window.TypeaheadList (static/js/utils/typeahead-list.js). Both components call TypeaheadList.filter(listEl, query, {valueAttr, disabledAttr?, separator?}) for the text match and TypeaheadList.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 FinanceFilterMixin with a scope + YYYY-MM period pair. 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 via templates/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.