Aller au contenu

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 (canonical extra_tags='toast info' + IMPORT_STARTED_MSG/IMPORT_ERROR_MSG constants); 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 (canonical hx-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

  1. Channel by intent, not by colour. Async-launch success → toast. Page-scoped warning / error / informational result → inline banner. Destructive action → confirmation (HTMX hx-confirm or full-page _confirm_delete.html). Validation error → field-level (handled by form-enhancements.js, see guidelines/ui/forms.md). One channel per signal — never both.
  2. 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 other messages.*() calls render as banners — and that is the desired behaviour.
  3. Destructive confirmations use the established pattern, never an ad-hoc confirm(). HTMX-driven row delete → hx-confirm="{% trans '...' %}". Non-HTMX delete → a full-page apps/<app>/templates/<app>/<model>_confirm_delete.html view with the standard structure (heading + details card + irreversibility alert + destructive button + cancel link). Don't sprinkle inline onclick="return confirm(...)" or onsubmit="return confirm(...)" for new code.
  4. Bulk operations report as a summary, not per-row. Count successes and failures, then emit one message: messages.success if all succeeded; messages.warning if some failed; messages.error only if zero succeeded. The user sees one line, not N. Reference: apps/sync/views.py:bulk_sync_practices.
  5. All user-facing strings are French and _() / {% trans %}-wrapped. Every rule below assumes this. See guidelines/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 tags contain 'toast' render as a Bootstrap toast in a fixed top-right .toast-container and auto-show via the bundled DOMContentLoaded script (5 s autohide).
  • All other messages render as inline .alert.alert-{tag}.alert-dismissible.fade.show blocks 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."

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-Trigger response header dispatches a browser event for cross-component signalling. Used by apps/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, use hx-confirm; for plain forms, use a _confirm_delete.html view; for one-off banners, use messages.warning after 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.html template that skips the irreversibility alert — the user must see the consequences before clicking the red button.

Known deviations

  • MESSAGE_TAGS not configured. Django's default tag for messages.ERROR is 'error', which makes messages.html render class="alert-error" — Bootstrap has no such class, so error banners render unstyled. Tracked as roadmap/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 as roadmap/backlog/ux-confirm-pattern-convergence.md.
  • _confirm_delete.html structural drift. 8 templates exist; assembly_confirm_delete.html is the canonical structure but dentist_confirm_delete.html and practice_confirm_delete.html are minimalist (no details card, no irreversibility alert), and patient_confirm_delete.html uses the deprecated card-header bg-danger text-white + <h5> pattern. Tracked as roadmap/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_delete convergence 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 success for the save, one info for 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 before hx-confirm would help) — acceptable as a stopgap, but flag for migration during the next adjacent edit. New code goes through hx-confirm or a _confirm_delete.html view.
  • 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.