List pages¶
Status: Implemented Last reviewed: 2026-04-18 Sources of truth:
templates/skeletons/skeleton_list.html,templates/components/pagination.html,templates/components/pagination_top.html,templates/components/table_header.html. Reference implementations:apps/practices/templates/practices/partials/practice_list_content.html,apps/patients/templates/patients/patient_list.html,apps/register/templates/register/holder_list.html.
Scope¶
Conventions for any page whose primary content is a paginated table of model rows: file layout, heading, table classes, action column, empty states, pagination, density.
Out of scope (covered elsewhere):
- Sorting and filtering (URL state, cookies, {% table_header %} semantics, SearchSelect widget, scope filters) → guidelines/ui/search-sort-filter.md. Always read that first if your view has sortable or filterable columns; this file references it but does not duplicate it.
- Status badges → guidelines/ui/badges.md.
- Design tokens (colors, shadows, radii) → guidelines/ui/design-tokens.md.
Hard rules¶
- Card-wrapped table. The table lives inside
<div class="card border-0 shadow-sm"><div class="card-body">…</div></div>. Theborder-0is intentional —base.cssputs a border on every card globally (don't fight it; see CLAUDE.md gotcha). No bare<table>outside a card. - Heading bar with title + count + action button. Always render the count next to the title and the primary action (usually "Add") on the right. Even on small lists. Users orient themselves by counts.
- Two empty states, not one. "No records exist yet" looks different from "filters returned nothing." Keep both code paths (see Empty states below).
- HTMX-friendly partial layout. If filters / sorting use HTMX (and they almost always should via
SortableFilterableListMixin), put the renderable content inpartials/<model>_list_content.htmland have the outer template just{% include %}it. The partial is what the HTMX response returns. - Action cells use
<td class="table-actions">withbtn-action-view/btn-action-edit/btn-action-delete— never raw Bootstrapbtn-*colors. Action buttons are sizedbtn-sm. - Read
guidelines/ui/search-sort-filter.mdbefore adding any sortable or filterable column. Use{% table_header %}— never hand-roll?ordering=links.
File layout¶
For a list view that uses HTMX (the default — SortableFilterableListMixin returns the partial on HTMX requests):
apps/<app>/templates/<app>/
<model>_list.html # outer template — extends base.html, owns <head>, filters block
partials/
<model>_list_content.html # the partial — heading + table + pagination
The outer template renders the partial inside an <div id="list-content"> wrapper; the partial includes everything inside that wrapper. The HTMX target is #list-content.
For a small list with no HTMX needs, a single template is fine (see apps/patients/templates/patients/patient_list.html — 14 lines).
Required structure¶
Outer template (<model>_list.html)¶
{% extends 'base.html' %}
{% load i18n practice_filter_tags %}
{% block title %}{% trans "Cabinets" %} - Aletheia{% endblock %}
{# Filters appear in the page chrome above the main content. #}
{% block page_filters %}
{% practice_filter selected_ids=selected_practice_ids htmx_target="#list-content" practice_counts=practice_counts %}
{# Add period_filter, dentist_filter as needed #}
{% endblock %}
{% block content %}
<div id="list-content">
{% include 'practices/partials/practice_list_content.html' %}
</div>
{% endblock %}
page_filters is a base.html block that renders the active scope/period/practice/dentist filter widgets above the page content — see guidelines/ui/search-sort-filter.md for which filters to use and how they wire up.
Inner partial (partials/<model>_list_content.html)¶
{% load i18n table_tags %}
<div class="row">
<div class="col-12">
{# --- Heading --- #}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-0"><i class="bi bi-building"></i> {% trans "Cabinets" %}</h2>
<small class="text-muted">{{ total_practices }} {% trans "cabinet" %}{{ total_practices|pluralize:"s" }}</small>
</div>
<a href="{% url 'practices:practice_create' %}" class="btn btn-action-primary">
<i class="bi bi-plus-circle"></i> {% trans "Ajouter un cabinet" %}
</a>
</div>
{# --- Card-wrapped table --- #}
<div class="card border-0 shadow-sm">
<div class="card-body">
{% if practices or active_filters_count %}
{# Table layout — used when records exist OR filters are active #}
{# pagination_top is optional; render for tables with > 25 rows for top-anchor jump #}
{% 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 %}
{% table_header 'name' _("Nom") sortable=True filterable=True %}
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for practice in practices %}
<tr>
<td><strong>{{ practice.internal_code }}</strong></td>
<td>{{ practice.display_name }}</td>
<td class="table-actions">
<a href="{% url 'practices:practice_detail' practice.pk %}" class="btn btn-sm btn-action-view"
data-bs-toggle="tooltip" title="{% trans 'Voir' %}">
<i class="bi bi-eye"></i>
</a>
<a href="{% url 'practices:practice_update' practice.pk %}" class="btn btn-sm btn-action-edit"
data-bs-toggle="tooltip" title="{% trans 'Modifier' %}">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
{% empty %}
{# "Filters returned nothing" empty row #}
<tr>
<td colspan="3" class="text-center py-4 text-muted">
<i class="bi bi-search"></i> {% trans "Aucun cabinet trouvé pour ces filtres." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include 'components/pagination.html' %}
{% else %}
{# "No records exist yet" empty state — distinct from "filters returned nothing" #}
<div class="text-center py-5">
<i class="bi bi-building text-muted" style="font-size: 4rem;"></i>
<p class="text-muted mt-3">{% trans "Aucun cabinet trouvé." %}</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
Heading¶
- Container:
d-flex justify-content-between align-items-center mb-3. - Title:
<h2 class="mb-0">with a Bootstrap Icon, then a<small class="text-muted">count line underneath (not inline). Use{{ count }} {% trans "noun" %}{{ count|pluralize:"s" }}for proper pluralisation. - Action button:
<a href="..." class="btn btn-action-primary">with<i class="bi bi-plus-circle"></i>and an "Ajouter X" label. Use a real URL — neverhref="#"outside the skeleton.
If the page has secondary actions (export, batch operations), place them as a <div class="d-flex gap-2"> group with primary on the right.
Table¶
- Wrap in
<div class="table-responsive">so wide tables scroll horizontally on small screens. - Classes:
table table-hover table-sm table-dense. Hover for row affordance,table-sm+table-densefor the project's compact density (uses--font-size-base13px). - Thead:
class="table-light"(auto-adapts in dark mode via base.css overrides). - Column headers: use
{% table_header field _("Label") sortable=True filterable=True %}for sortable / filterable columns; plain<th>for action columns and computed columns (e.g. derived counts that aren't real fields). Seeguidelines/ui/search-sort-filter.mdfor the full tag signature. - Numeric / count columns: add
css_class="text-center"on the{% table_header %}andclass="text-center"on the<td>. Currency / large numbers should be right-aligned (text-end) instead.
Cell content¶
| Cell type | Pattern |
|---|---|
| Identifier / code | <td><strong>{{ obj.code }}</strong></td> (bold for visual anchor) |
| Free text | <td>{{ obj.name }}</td> |
| Optional text | <td>{{ obj.city|default:"-" }}</td> (use - for empty, never blank) |
| Status flag | <span class="badge badge-status-active">{% trans "Actif" %}</span> (per guidelines/ui/badges.md) |
| Count | <td class="text-center">{{ obj.dentist_count }}</td> |
| Currency | <td class="text-end">{{ obj.amount|floatformat:2 }} €</td> |
| Date | <td>{{ obj.created_at|date:"d/m/Y" }}</td> (French short format) |
| Boolean | Render as a status badge (Oui / Non), never as a raw checkbox icon |
Empty states¶
There are two distinct empty conditions and they look different:
1. "No records exist yet" — {% if not records and not active_filters_count %} path:
- Centered, large icon (style="font-size: 4rem;"), muted color.
- One-line caption (<p class="text-muted mt-3">{% trans "Aucun ..." %}</p>).
- May include a secondary CTA below the caption ("Importer", "Créer le premier"). Do not duplicate the heading-bar primary action.
2. "Filters returned nothing" — {% empty %} inside {% for %} when records could exist but the active filters yield zero rows:
- Empty <tr> with <td colspan="N" class="text-center py-4 text-muted">.
- Search icon + caption: <i class="bi bi-search"></i> {% trans "Aucun X trouvé pour ces filtres." %}.
- The table chrome (header row, filters, pagination) stays visible so the user can adjust filters without leaving the page.
The trigger is {% if records or active_filters_count %} — when filters are active, render the table layout with the empty <tbody> row, not the "no records" empty state. active_filters_count is supplied by SortableFilterableListMixin.
Action column¶
Use <td class="table-actions"> with these button classes (defined in base.css):
| Button | Class | Icon | Use for |
|---|---|---|---|
| View | btn btn-sm btn-action-view |
bi-eye |
Link to detail page |
| Edit | btn btn-sm btn-action-edit |
bi-pencil |
Link to edit form |
| Delete | btn btn-sm btn-action-delete |
bi-trash |
Delete action (usually opens a confirm modal) |
Always wrap the icon-only button in a tooltip:
<a href="..." class="btn btn-sm btn-action-view"
data-bs-toggle="tooltip" title="{% trans 'Voir' %}">
<i class="bi bi-eye"></i>
</a>
Initialise tooltips in extra_js of the outer template (Bootstrap doesn't auto-init).
If you have more than 3 actions, collapse the secondary ones into a dropdown (<div class="dropdown">) with the same btn-sm sizing.
Pagination¶
Two partials, both in templates/components/:
pagination_top.html— rendered above the table for tables long enough that the bottom pagination scrolls out of view. Optional. Render when the page has > 25 rows or the table is the page's only content.pagination.html— rendered below the table. Always render when there are pages.
Both partials read page_obj and the request's GET params from context — no extra wiring needed if you use SortableFilterableListMixin.
The per-page selector lives in its own partial (per_page_select.html) and is included by pagination.html automatically.
JS hookup¶
The outer template's extra_js should at minimum initialise tooltips:
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
});
</script>
{% endblock %}
If the list uses SearchSelect filters or HTMX-driven sort/filter, that wiring is handled by the mixin + the components named in guidelines/ui/search-sort-filter.md — no per-page JS needed.
Density and responsiveness¶
- The project standard is dense:
table-sm table-dense. Don't relax this for "comfort" — users scan large datasets and density matters more than padding. - For pages with very wide tables (>10 columns), consider:
- Horizontal scroll via
<div class="table-responsive">(default — already in the skeleton). - Hiding low-priority columns on small screens with Bootstrap responsive utilities (
d-none d-md-table-cell). - Not introducing a card-grid alternative layout — the project converged on tables for consistency.
- Heading + filters block must remain visible on mobile. Filters wrap below the title on
<= sm— no special handling needed if you use the standard filter components.
Anti-patterns¶
- ❌ Bare
<table>outside a card wrapper. - ❌ Hand-rolled
?ordering=col&filter_x=...href construction. Use{% table_header %}and the mixin's URL handling. - ❌ Using
<th>with raw classes for sortable columns — use{% table_header sortable=True %}so the chevron + URL state are consistent. - ❌ Bootstrap colours on action buttons (
btn-primary,btn-danger) — usebtn-action-view/edit/delete. - ❌ Single empty state for both "no records" and "filtered out" — users get confused about whether to adjust filters or import data.
- ❌ Inline icon + text in the heading without count — counts orient users.
- ❌ Hardcoded language in
data-bs-toggle="tooltip"titles — wrap in{% trans %}. - ❌ Adding a "comfortable" toggle. Density is the project standard.
- ❌ Adding pagination markup by hand instead of
{% include 'components/pagination.html' %}. - ❌ Putting the action button on the left ("Add" reads as the primary action, lives on the right).
Known deviations¶
The 2026-04 search-sort-filter audit found 0 list-page conformance issues — all paginated lists compose SortableFilterableListMixin and use {% table_header %}. The single tolerated divergence is the finance module's FinanceFilterMixin (tracked in roadmap/backlog/finance-filter-unify.md). See guidelines/ui/search-sort-filter.md "Known deviations" for the current state.
When to break the rules¶
- Dashboards (e.g.
apps/budgets/views.py BudgetDashboardView) areTemplateView-based aggregate pages, not paginated lists. They legitimately bypass theSortableFilterableListMixinstack. - Kanban / pipeline views (e.g.
apps/crm/prospect pipeline) use a column layout, not a table. Don't try to force them into the list pattern; they have their own structure. - Embedded mini-lists inside a detail page (e.g. last 5 movements on an entity detail) skip the heading bar and pagination — they're a card body, not a page. Still use the same table classes (
table table-hover table-sm) for visual consistency. - API-backed list pages in
apps/websites/use DRF generics, not Django CBVs. The template patterns still apply; the view layer differs.