Feedback and confirmation¶
Status: Implemented Last reviewed: 2026-04-20 Sources of truth:
templates/components/messages.html(toast + banner rendering);templates/base.html(<body data-i18n-*>attrs, global messages include);apps/imports/views.py(canonicalextra_tags='toast info'+IMPORT_STARTED_MSG/IMPORT_ERROR_MSGconstants);apps/sync/views.py:bulk_sync_practices(canonical bulk partial-failure summary);apps/register/templates/register/assembly_confirm_delete.html(canonical full-page destructive confirmation);apps/practices/templates/practices/partials/business_hours_list.html(canonicalhx-confirm);apps/finance/templates/finance/close_workflow.html(canonical input-capture modal).
Scope¶
How users learn that something happened (or is about to happen): the choice between toast / inline banner / full-page confirmation / modal / field-level error, the Django messages framework conventions, the destructive-confirmation patterns (hx-confirm for HTMX, _confirm_delete.html view for full-page), and the bulk-operation partial-failure summary.
This file is system-wide interaction patterns. Per-form validation styling lives in guidelines/ui/forms.md. Status colours (the four badge-status-* tints) live in guidelines/ui/badges.md. Empty / loading / error page-level states live in guidelines/ux/page-states.md (Placeholder).
Hard rules¶
- Channel by intent, not by colour. Async-launch success → toast. Page-scoped warning / error / informational result → inline banner. Destructive action → confirmation (HTMX
hx-confirmor full-page_confirm_delete.html). Validation error → field-level (handled byform-enhancements.js, seeguidelines/ui/forms.md). One channel per signal — never both. - Toast is opt-in via
extra_tags='toast …'. Default Django messages render as banners. A toast is appropriate only when the user kicks off something that completes elsewhere (Celery task launched, async job started). All othermessages.*()calls render as banners — and that is the desired behaviour. - Destructive confirmations use the established pattern, never an ad-hoc
confirm(). HTMX-driven row delete →hx-confirm="{% trans '...' %}". Non-HTMX delete → a full-pageapps/<app>/templates/<app>/<model>_confirm_delete.htmlview with the standard structure (heading + details card + irreversibility alert + destructive button + cancel link). Don't sprinkle inlineonclick="return confirm(...)"oronsubmit="return confirm(...)"for new code. - Bulk operations report as a summary, not per-row. Count successes and failures, then emit one message:
messages.successif all succeeded;messages.warningif some failed;messages.erroronly if zero succeeded. The user sees one line, not N. Reference:apps/sync/views.py:bulk_sync_practices. - All user-facing strings are French and
_()/{% trans %}-wrapped. Every rule below assumes this. Seeguidelines/i18n/translation-rules.md. The_(f"…")antipattern is enforced by the conformance hook (Rule 5, block-severity).
Channel decision tree¶
| Situation | Channel | How |
|---|---|---|
| Async job launched ("import started", "sync queued") | Toast (auto-dismiss, top-right) | messages.add_message(request, messages.INFO, MSG, extra_tags='toast info') |
| Synchronous success ("Patient enregistré", "Statut mis à jour") | Banner (top of content, dismissible) | messages.success(request, _("...")) |
| Recoverable warning ("Aucun cabinet sélectionné", partial-failure summary) | Banner (warning) | messages.warning(request, _("...")) |
| Server-side error after the action ("Action inconnue", "Erreur de synchronisation : %(error)s") | Banner (danger) | messages.error(request, _("...")) |
| Field validation error ("Ce champ est obligatoire") | Field-level | Handled by form-enhancements.js — see guidelines/ui/forms.md |
| Form-level non-field error ("Erreurs dans le formulaire") | Inline error block below the last fieldset | The {% if form.errors %} block in templates/skeletons/skeleton_form.html |
| About-to-do destructive action (delete row, drop record) | Confirmation | hx-confirm (HTMX) or _confirm_delete.html view (non-HTMX) |
| About-to-do destructive edit (overwrite legal/historical data) | Two-step confirm on the form | Alpine confirming flag — see guidelines/ui/forms.md "Edit mode" |
| Need extra input before completing a privileged action (close-period reopening with reason) | Modal | Inline <div class="modal fade"> — see "When a modal is justified" |
Django messages framework¶
The framework drives both banner and toast rendering. templates/components/messages.html is included unconditionally from templates/base.html and dispatches by tag:
- Messages whose
tagscontain'toast'render as a Bootstrap toast in a fixed top-right.toast-containerand auto-show via the bundled DOMContentLoaded script (5 s autohide). - All other messages render as inline
.alert.alert-{tag}.alert-dismissible.fade.showblocks above the page content.
Toast — the canonical pattern¶
Use module-level constants (per guidelines/i18n/translation-rules.md "Repeated messages — module-level constants") so the same string is shared across views:
# apps/imports/views.py
from django.contrib import messages
from django.utils.translation import gettext_lazy as _
IMPORT_STARTED_MSG = _("Import lancé avec succès ! Vous pouvez suivre sa progression en temps réel.")
IMPORT_ERROR_MSG = _("Erreur lors du lancement de l'import : %(error)s")
class DentistImportView(...):
def form_valid(self, form):
try:
process_dentist_import_task.delay(import_record.id)
messages.add_message(
self.request, messages.INFO, IMPORT_STARTED_MSG,
extra_tags='toast info',
)
except Exception as e:
messages.error(self.request, IMPORT_ERROR_MSG % {'error': str(e)})
return redirect('imports:import_detail', pk=import_record.pk)
extra_tags='toast info' is two tokens: toast triggers the toast renderer; the second token (info / success / warning / error) selects the Bootstrap colour (.text-bg-{tag}). The renderer strips the leading toast and uses the remainder.
Use a toast only when the action is async. A synchronous messages.success(request, _("Patient enregistré")) is a banner — the user reads it on the redirect target, dismisses it on their own pace. A toast that auto-dismisses in 5 s is the wrong vehicle for "I just clicked Save and the page reloaded."
Banner — the default¶
messages.success(request, _("Statut mis à jour."))
messages.warning(request, _("Aucun cabinet sélectionné."))
messages.error(request, _("Erreur lors du lancement de l'import : %(error)s") % {'error': str(e)})
The banner appears at the top of <main>, after breadcrumbs and before page content. It's dismissible (the .btn-close is rendered automatically). No further wiring needed.
Variable interpolation: percent-format placeholders¶
messages.success(request, _("Synchronisation réussie pour %(name)s.") % {'name': practice.display_name})
Never _(f"…") (silently never translates — see guidelines/i18n/translation-rules.md). Use consistent placeholder names so semantically-identical messages collapse to the same msgid: %(name)s for entity names, %(count)s for counts, %(error)s for exception strings.
Destructive confirmations¶
HTMX-driven actions → hx-confirm¶
<button class="btn btn-sm btn-action-delete"
hx-post="{% url 'practices:business_hour_delete' practice.pk hour.pk %}"
hx-target="#business-hours-content" hx-swap="innerHTML"
hx-confirm="{% trans 'Supprimer cet horaire ?' %}">
<i class="bi bi-trash"></i>
</button>
HTMX issues a native window.confirm() before sending the request. One short, French, action-oriented sentence ending in ?. Don't interpolate dynamic values into hx-confirm — the dialog is a yes/no, not an audit log; "Supprimer cet horaire ?" is enough.
Non-HTMX destructive actions → full-page confirmation view¶
For DELETEs that route through a regular Django DeleteView, render a <model>_confirm_delete.html template. Reference: apps/register/templates/register/assembly_confirm_delete.html. The canonical structure:
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">
<i class="bi bi-exclamation-triangle text-warning"></i>
{% trans "Supprimer une assemblée" %}
</h2>
<a href="{% url '...:detail' object.pk %}" class="btn btn-action-secondary">
<i class="bi bi-arrow-left"></i> {% trans "Annuler" %}
</a>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header card-header-neutral">
<i class="bi bi-..."></i> {% trans "Détails de …" %}
</div>
<div class="card-body">
<p class="mb-2"><strong>{% trans "Champ" %} :</strong> {{ object.field }}</p>
{# More key/value rows per guidelines/ui/detail-pages.md #}
</div>
</div>
<div class="alert alert-warning" role="alert">
<i class="bi bi-exclamation-triangle"></i>
{% blocktrans %}Cette action est <strong>irréversible</strong>. … (consequences){% endblocktrans %}
</div>
<form method="post">
{% csrf_token %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-action-destructive">
<i class="bi bi-trash"></i> {% trans "Supprimer définitivement" %}
</button>
<a href="{% url '...:detail' object.pk %}" class="btn btn-action-secondary">
<i class="bi bi-x-circle"></i> {% trans "Annuler" %}
</a>
</div>
</form>
</div>
</div>
{% endblock %}
Required elements:
- Heading with bi-exclamation-triangle text-warning icon and a French verb-headed title (Supprimer une assemblée).
- Details card so the user verifies which record they're about to delete (key/value <p class="mb-2"><strong>Label :</strong> value</p> rows per guidelines/ui/detail-pages.md — never a layout <table>).
- Irreversibility alert (alert alert-warning) explaining downstream consequences (cascaded deletes, lost links, audit trail). One paragraph, French, with <strong>irréversible</strong> if applicable.
- btn-action-destructive (red) for the submit, btn-action-secondary for the cancel link back to the detail / list.
Two-step confirmation for destructive edits on the same page¶
Editing legal / historical data (not deleting) on a form already open: use the Alpine confirming flag pattern documented in guidelines/ui/forms.md "Edit mode". Don't navigate away to a confirmation view; the form is already in front of the user.
Bulk operations — the partial-failure summary¶
When N rows are processed and some fail, do NOT enqueue N messages. Count and summarise. Reference: apps/sync/views.py:bulk_sync_practices.
success_count = 0
error_count = 0
for practice in selected:
sync_log = service.sync_practice(practice)
if sync_log.status == 'success':
success_count += 1
else:
error_count += 1
if error_count == 0:
messages.success(
request,
_("Synchronisation réussie pour %(count)s cabinet(s).") % {'count': success_count},
)
elif success_count == 0:
messages.error(
request,
_("Synchronisation échouée pour %(count)s cabinet(s).") % {'count': error_count},
)
else:
messages.warning(
request,
_("Synchronisation : %(success)s réussi(s), %(error)s erreur(s).") % {
'success': success_count, 'error': error_count,
},
)
Three-way branch: all-OK = success, mixed = warning, all-fail = error. The user gets one banner.
If individual error details matter (e.g. for an import), surface them on the detail view of the run (e.g. imports:import_detail shows per-row errors via ImportRow). The bulk message is the summary; the detail view is the audit log.
When a modal is justified¶
Modals are reserved for input capture mid-workflow — when the next action needs extra information that isn't worth a separate page. Reference: apps/finance/templates/finance/close_workflow.html (the "Réouverture de période" modal captures a free-text motif before reopening).
Required:
- Bootstrap 5 markup (<div class="modal fade" id="..."> … <div class="modal-dialog">).
- Modal title uses <h5 class="modal-title"> with an icon (modal headers are NOT card-header-neutral, so the <h5> rule from guidelines/ui/detail-pages.md does not apply here).
- data-bs-toggle="modal" + data-bs-target on the trigger button. Wire any prefill in a tiny script via the show.bs.modal event.
- Footer has btn-action-secondary for cancel + the action button (often btn-warning or btn-action-destructive depending on severity).
- Form method="post" with {% csrf_token %} so the modal is a real form, not a fake.
Do not use a modal for confirmation alone (a hx-confirm or full-page view is enough). Do not open a modal that itself opens another modal.
HTMX response feedback¶
Two patterns coexist:
- Server-side
messages.*()+ redirect, then the banner appears on the next page render. Default for any state-changing action that ends in a redirect. HX-Triggerresponse header dispatches a browser event for cross-component signalling. Used byapps/websites/views_cms.py(response['HX-Trigger'] = 'blockSaved') so other DOM elements can react. Use sparingly — only when a banner / toast is the wrong primitive (e.g. you need to refresh a sibling list without a full page redraw).
OOB swaps (hx-swap-oob="innerHTML") on a result-count badge are fine and don't need a paired feedback message — the badge change is the feedback.
Anti-patterns¶
- ❌
_(f"…")— silently never translates. Conformance hook Rule 5 (block). - ❌
onclick="return confirm('{% trans \"…\" %}' {{ object.field }} ?')"— string-stitching at template + JS layer is fragile, breaks translation reordering, and risks XSS if the value is unescaped. For HTMX, usehx-confirm; for plain forms, use a_confirm_delete.htmlview; for one-off banners, usemessages.warningafter the action. - ❌ Toast for failure — toasts auto-dismiss in 5 s, so an error message disappears before the user reads it. Use a banner (
messages.error). - ❌ Toast for synchronous success — the user already saw the page change. A banner is the right vehicle.
- ❌ Per-row
messages.*()in a bulk loop — N banners stack and overwhelm. Summarise. - ❌ Field-level validation error duplicated as a top-of-form banner — the form-errors block is for non-field errors only. Field errors render below the field via
<div class="invalid-feedback">. - ❌ Modal opening a modal — re-architect the workflow.
- ❌ A
_confirm_delete.htmltemplate that skips the irreversibility alert — the user must see the consequences before clicking the red button.
Known deviations¶
MESSAGE_TAGSnot configured. Django's default tag formessages.ERRORis'error', which makesmessages.htmlrenderclass="alert-error"— Bootstrap has no such class, so error banners render unstyled. Tracked asroadmap/backlog/ux-messages-error-tag-bootstrap-mismatch.md.- Inline
confirm()patterns in 4 sites (apps/budgets/templates/budgets/timeoff_list.html,apps/pennylane_sync/templates/pennylane_sync/dashboard.html,apps/pennylane_sync/templates/pennylane_sync/entity_detail.html,apps/sync/templates/sync/practitioner_review.html). Tracked asroadmap/backlog/ux-confirm-pattern-convergence.md. _confirm_delete.htmlstructural drift. 8 templates exist;assembly_confirm_delete.htmlis the canonical structure butdentist_confirm_delete.htmlandpractice_confirm_delete.htmlare minimalist (no details card, no irreversibility alert), andpatient_confirm_delete.htmluses the deprecatedcard-header bg-danger text-white+<h5>pattern. Tracked asroadmap/backlog/ux-confirm-delete-template-convergence.md.<body data-i18n-confirm-delete>is set but unused. No JS reads it. Tracked as report-only — keep or drop after the_confirm_deleteconvergence settles.
When to break the rules¶
- Inline alert banner for static page-scoped state (info notice on a list page about scope, warning on a partials/inpi_card.html when a remote source is missing) is fine — those aren't
messages.*()results, they're conditional template content. Keep using<div class="alert alert-{warning,info,danger}">directly. - Multiple banners after one user action if they're describing different facets (e.g. one
successfor the save, oneinfofor a follow-up suggestion). The "one channel per signal" rule is about avoiding duplicates, not about capping at one message per request. - Native
confirm()in a JS handler that runs before an HTMX request fires (and therefore beforehx-confirmwould help) — acceptable as a stopgap, but flag for migration during the next adjacent edit. New code goes throughhx-confirmor a_confirm_delete.htmlview. - Modal for a quick non-input choice (e.g. "save as draft / save and publish") — acceptable when the answer affects the POST payload meaningfully and a separate page would feel heavyweight. Justify in the PR description.