Aller au contenu

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

  1. Card-wrapped table. The table lives inside <div class="card border-0 shadow-sm"><div class="card-body">…</div></div>. The border-0 is intentional — base.css puts a border on every card globally (don't fight it; see CLAUDE.md gotcha). No bare <table> outside a card.
  2. 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.
  3. Two empty states, not one. "No records exist yet" looks different from "filters returned nothing." Keep both code paths (see Empty states below).
  4. HTMX-friendly partial layout. If filters / sorting use HTMX (and they almost always should via SortableFilterableListMixin), put the renderable content in partials/<model>_list_content.html and have the outer template just {% include %} it. The partial is what the HTMX response returns.
  5. Action cells use <td class="table-actions"> with btn-action-view / btn-action-edit / btn-action-delete — never raw Bootstrap btn-* colors. Action buttons are sized btn-sm.
  6. Read guidelines/ui/search-sort-filter.md before 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 — never href="#" 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-dense for the project's compact density (uses --font-size-base 13px).
  • 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). See guidelines/ui/search-sort-filter.md for the full tag signature.
  • Numeric / count columns: add css_class="text-center" on the {% table_header %} and class="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) — use btn-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) are TemplateView-based aggregate pages, not paginated lists. They legitimately bypass the SortableFilterableListMixin stack.
  • 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.