Aller au contenu

Form pages

Status: Implemented Last reviewed: 2026-04-18 Sources of truth: templates/skeletons/skeleton_form.html, static/css/form-enhancements.css, static/js/form-enhancements.js. Reference implementations: apps/register/templates/register/holder_form.html (contextual), apps/dentists/templates/dentists/dentist_form.html, apps/practices/templates/practices/practice_form.html.

Scope

Conventions for create / edit form pages: layout, fieldset structure, field rendering, inline validation, auto-formatting, contextual visibility (Alpine), edit-mode safeguards, button placement, JS wiring.

This file covers template-side form construction. Python-side form construction (Django Form / ModelForm, validators, clean_* methods, SearchSelect widget configuration) lives in guidelines/backend/forms.md (Placeholder).

Hard rules

  1. No outer <div class="card"> wrapper. Each <fieldset class="form-section"> is the card. Wrapping the whole form in a card creates nested borders that fight the form-section styling.
  2. No Crispy Forms / {{ form.as_p }} / {{ form.as_table }}. Render fields manually inside <fieldset class="form-section"> so the validation classes, char counters, tooltips, and auto-format hooks all wire correctly.
  3. Always load form-enhancements.css and form-enhancements.js. They're the behavioural layer — without them, validation, char counters, keyboard shortcuts, and auto-formatting are dead.
  4. Form id must end in -form. The JS auto-discovery uses form[id$="-form"] to find forms to enhance.
  5. Manual rendering uses form-label on the label and lets the widget render itself ({{ form.field }}) — never re-style the input via class="..." on the <input>. Styling lives in form-enhancements.css and applies to .form-control / .form-select, which Django's widgets emit by default.

Required structure

Copy from templates/skeletons/skeleton_form.html. The minimal shell:

{% extends 'base.html' %}
{% load static i18n %}

{% block extra_css %}<link rel="stylesheet" href="{% static 'css/form-enhancements.css' %}">{% endblock %}

{% block content %}
<div class="row">
    <div class="col-md-10 offset-md-1">
        <h2 class="mb-4"><i class="bi bi-..."></i> {{ form_title }}</h2>

        <form method="post" id="entity-form" novalidate>
            {% csrf_token %}

            <fieldset class="form-section">
                <legend class="form-section-title">
                    <i class="bi bi-info-circle"></i> {% trans "Informations de base" %}
                </legend>
                <div class="row">
                    <div class="col-md-6">
                        <div class="mb-3">
                            <label for="{{ form.name.id_for_label }}" class="form-label required-field">
                                {{ form.name.label }}
                            </label>
                            {{ form.name }}
                            <div class="invalid-feedback"></div>
                        </div>
                    </div>
                </div>
            </fieldset>

            {# Repeat fieldsets per logical section #}

            {% if form.errors %}
            <div class="alert alert-danger" role="alert">
                <h6><i class="bi bi-exclamation-triangle"></i> {% trans "Erreurs dans le formulaire" %} :</h6>
                <ul class="mb-0">
                    {% for field in form %}{% for error in field.errors %}<li>{{ field.label }}: {{ error }}</li>{% endfor %}{% endfor %}
                    {% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
                </ul>
            </div>
            {% endif %}

            <div class="d-flex gap-2 mt-4">
                <button type="submit" class="btn btn-action-primary">
                    <i class="bi bi-check-circle"></i> {% trans "Enregistrer" %}
                </button>
                <a href="{% url '...' %}" class="btn btn-action-secondary">
                    <i class="bi bi-x-circle"></i> {% trans "Annuler" %}
                </a>
            </div>
        </form>
    </div>
</div>
{% endblock %}

{% block extra_js %}<script src="{% static 'js/form-enhancements.js' %}"></script>{% endblock %}

Fieldset rules

  • One fieldset per logical section. Each gets a <legend class="form-section-title"> with a Bootstrap Icons icon and a translatable title.
  • Fields inside use the Bootstrap grid (<div class="row"><div class="col-md-N"><div class="mb-3">).
  • A <div class="invalid-feedback"></div> slot follows every input that opts into inline validation. The JS writes the error message into this element.

Required-field marker

Add class="form-label required-field" on the label (not the input). The CSS appends a red * via ::after. Don't write * in the label string.

Inline help and tooltips

  • Static help below the field: <div class="form-text">{% trans "..." %}</div>.
  • Hover tooltip on the label: append <i class="bi bi-question-circle text-muted" data-bs-toggle="tooltip" title="{% trans '...' %}"></i>. Initialise tooltips in extra_js:
    <script>
      document.addEventListener('DOMContentLoaded', function() {
          document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
      });
    </script>
    

Character counters

For textareas with a length cap, render the input with maxlength and add a counter span:

<div class="form-text">
    <span class="char-counter" data-target="{{ form.description.id_for_label }}">0</span> / 500 {% trans "caracteres" %}
</div>
The JS auto-flips the counter to text-warning at 75% and text-danger at 90%.

Standalone checkbox in a fieldset

A checkbox that is the direct child of a .form-section (no surrounding <div class="row">) gets automatic left padding from form-enhancements.css to compensate for Bootstrap's negative margin-left on .form-check-input. Do not add custom padding — the CSS handles it.

<fieldset class="form-section">
    <legend class="form-section-title"><i class="bi bi-toggle-on"></i> {% trans "Statut" %}</legend>
    <div class="mb-3 form-check">
        {{ form.active }}
        <label class="form-check-label" for="{{ form.active.id_for_label }}">{{ form.active.label }}</label>
    </div>
</fieldset>

Inline validation and auto-formatting

The JS reads data-* attributes from the rendered widget. Set them in the widget's attrs on the Django form side.

Attribute Effect
data-validate="required" Show error if empty (combine with HTML required)
data-validate="email" French-locale email validation
data-validate="phone" 9 or 10 digits
data-validate="postal-code" 5 digits
data-validate="url" Must start with http:// or https://
data-auto-format="phone" Live-format as 01 23 45 67 89
data-auto-format="postal-code" Strip non-digits, cap at 5

Validation runs on blur, again on submit, and live (input) once a field has been validated at least once. The submit handler scrolls to the first error and inserts a top-of-form alert.

Validation messages are read from <body data-i18n-*> attributes (set by base.html) so they're localisable. Don't hardcode them in JS.

Contextual forms (Alpine.js)

When fields should appear/disappear based on other field values, use Alpine on the <form> element. Reference: apps/register/templates/register/holder_form.html.

The pattern

<form method="post" novalidate
      x-data="{
          type: '{{ form.holder_type.value|default_if_none:'' }}',
          entityId: '{{ form.entity.value|default_if_none:'' }}',
          confirming: false
      }"
      @change="
          if ($event.target.name === 'holder_type') { type = $event.target.value; }
          if ($event.target.name === 'entity') {
              entityId = $event.target.value;
              if (entityId) { type = 'entity'; }   /* bidirectional: picking an entity sets the type */
          }
      ">
    {% csrf_token %}

    <fieldset class="form-section">  {# always visible — drives state #}
        ...
        {{ form.holder_type }}
    </fieldset>

    <fieldset class="form-section" x-show="type === 'entity'" x-cloak>
        ...
    </fieldset>

    <fieldset class="form-section" x-show="type === 'individual' && !entityId" x-cloak>
        ...
    </fieldset>
</form>

Rules

  • State lives on x-data on the <form> — never on individual inputs. One state object, one source of truth.
  • @change on the form, not per-field handlers. Inputs dispatch up; the form mutates state. Avoids 20 hand-wired handlers.
  • Initialise from server-side values. x-data="{ type: '{{ form.field.value|default_if_none:'' }}' }" so the form reopens with the right sections shown after a validation error.
  • Always pair x-show with x-cloak. Otherwise you get a flash of every fieldset before Alpine boots. Add x-cloak to your global CSS as [x-cloak] { display: none !important; } (already in base.css).
  • Make the relationship bidirectional when it matters. In the holder form, picking an entity sets type='entity' automatically — the user can't end up with type=individual + an entity selected. Cross-field consistency is enforced in the @change handler.
  • Alpine x-show does not track getters. Use inline expressions (x-show="type === 'entity'") or invoke a method (x-show="isEntity()") — a getter referenced as x-show="isEntity" renders once and never re-evaluates. (Listed in CLAUDE.md "Gotchas" — re-stated here because contextual forms run into it.)
  • Don't hide required fields. A field with required that's x-show="false" still blocks form submission silently. Either remove required when the section is hidden, or — better — make the requirement contextual server-side in the form's clean().

Edit mode

Forms that edit an existing record (rather than create a new one) need extra safeguards. Reference: holder_form.

Modification warning at the top

If the edit can affect downstream data (legal documents, reports, contracts), show an alert immediately under the heading:

{% if form.instance.pk %}
<div class="alert alert-warning d-flex align-items-start" role="alert">
    <i class="bi bi-exclamation-triangle me-2 mt-1"></i>
    <div>
        <strong>{% trans "Modification d'un détenteur existant." %}</strong>
        {% trans "Les changements sur l'identité ... sont repris dans les documents légaux. Vérifiez avant d'enregistrer." %}
    </div>
</div>
{% endif %}

Two-step confirmation for destructive edits

When the edit overwrites historical / legal data, require an explicit second click:

<button type="button" class="btn btn-action-primary"
        x-show="!confirming" @click="confirming = true">
    <i class="bi bi-check-circle"></i> {% trans "Enregistrer" %}
</button>
<button type="submit" class="btn btn-action-destructive"
        x-show="confirming" x-cloak>
    <i class="bi bi-exclamation-triangle"></i> {% trans "Confirmer la modification" %}
</button>

State (confirming) lives in the same x-data object on the form. Don't add a new component for this.

JS conventions

  • Form ID ends in -form (e.g. dentist-form, holder-form). Auto-discovery in form-enhancements.js selects form[id$="-form"].
  • Keyboard shortcuts (free, automatic): Ctrl/Cmd + Enter and Ctrl/Cmd + S both submit the form.
  • Auto-focus the first <input autofocus> in the form (also free).
  • i18n in JS comes from <body data-i18n-required="..." data-i18n-email="..."> attributes. The shell template sets these — do not hardcode message strings in form JS.
  • Search-select widgets (e.g. dentist picker, entity picker): load <script src="{% static 'js/components/form-search-select.js' %}"></script> after form-enhancements.js. See guidelines/ui/dropdowns.md (Placeholder) for the widget contract.

Buttons

  • Save: <button type="submit" class="btn btn-action-primary"> with <i class="bi bi-check-circle"></i> {% trans "Enregistrer" %}.
  • Cancel: <a href="..." class="btn btn-action-secondary"> with <i class="bi bi-x-circle"></i> {% trans "Annuler" %}. Always a link back to the list or detail, never a <button> that resets the form.
  • Destructive confirm: btn-action-destructive with <i class="bi bi-exclamation-triangle"></i>. Used for the second step of two-step edits.
  • Wrap in <div class="d-flex gap-2 mt-4">. Save first, Cancel last. Don't reorder for "less commit" framing — primary action is leftmost by convention.

Anti-patterns

  • ❌ Wrapping the whole form in <div class="card">.
  • ❌ Using Crispy Forms ({% crispy form %}) — bypasses every enhancement.
  • ❌ Adding class="form-control" manually on the <input> — Django's widget already does this; double-classing breaks the validation styling.
  • ❌ Per-field @change Alpine handlers — push state up to the form.
  • ❌ Hardcoded JS error messages in inline <script> — use <body data-i18n-*> attributes.
  • <button type="submit"> for cancel — use <a> to a real URL.
  • ❌ Putting required-field * directly in the label string — use class="required-field" on the label.
  • x-show="someGetter" — does not track. Use an expression or someMethod().

Known deviations

  • Some older forms still use Bootstrap bg-* on alerts inside the form rather than the design-token-aware alert-* classes. Not blocking; will be swept by the 2026-04 audit backlog.
  • A handful of forms in apps/finance/ and apps/budgets/ have inline style= attributes for column widths. These should be moved to a small CSS class — listed in roadmap/backlog/ui-design-system-mechanical-fixes-2026-04.md.
  • The form-enhancements JS targets form[id$="-form"] exclusively. Any form whose id doesn't follow the convention silently misses out on validation, char counters, etc. Check this if behaviour seems missing.

When to break the rules

  • Inline / partial forms loaded by HTMX (e.g. apps/dentists/templates/dentists/partials/skill_form.html) often skip the <fieldset class="form-section"> wrapper because they're rendered into a modal or inline slot that already provides the visual frame. The field-level rules (manual rendering, form-label, invalid-feedback slot, validation data-* attrs) still apply.
  • Single-field "form" pages (e.g. password reset, single-input search) can drop the fieldset wrapper. Use a single .mb-3 block directly inside the <form>.