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¶
- 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. - 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. - Always load
form-enhancements.cssandform-enhancements.js. They're the behavioural layer — without them, validation, char counters, keyboard shortcuts, and auto-formatting are dead. - Form
idmust end in-form. The JS auto-discovery usesform[id$="-form"]to find forms to enhance. - Manual rendering uses
form-labelon the label and lets the widget render itself ({{ form.field }}) — never re-style the input viaclass="..."on the<input>. Styling lives inform-enhancements.cssand 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 inextra_js:
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>
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-dataon the<form>— never on individual inputs. One state object, one source of truth. @changeon 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-showwithx-cloak. Otherwise you get a flash of every fieldset before Alpine boots. Addx-cloakto your global CSS as[x-cloak] { display: none !important; }(already inbase.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@changehandler. - Alpine
x-showdoes not track getters. Use inline expressions (x-show="type === 'entity'") or invoke a method (x-show="isEntity()") — a getter referenced asx-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
requiredthat'sx-show="false"still blocks form submission silently. Either removerequiredwhen the section is hidden, or — better — make the requirement contextual server-side in the form'sclean().
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 inform-enhancements.jsselectsform[id$="-form"]. - Keyboard shortcuts (free, automatic):
Ctrl/Cmd + EnterandCtrl/Cmd + Sboth 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>afterform-enhancements.js. Seeguidelines/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-destructivewith<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
@changeAlpine 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 — useclass="required-field"on the label. - ❌
x-show="someGetter"— does not track. Use an expression orsomeMethod().
Known deviations¶
- Some older forms still use Bootstrap
bg-*on alerts inside the form rather than the design-token-awarealert-*classes. Not blocking; will be swept by the 2026-04 audit backlog. - A handful of forms in
apps/finance/andapps/budgets/have inlinestyle=attributes for column widths. These should be moved to a small CSS class — listed inroadmap/backlog/ui-design-system-mechanical-fixes-2026-04.md. - The form-enhancements JS targets
form[id$="-form"]exclusively. Any form whoseiddoesn'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-feedbackslot, validationdata-*attrs) still apply. - Single-field "form" pages (e.g. password reset, single-input search) can drop the fieldset wrapper. Use a single
.mb-3block directly inside the<form>.