{# @description One row of a formset: a leading hairline, a header naming the row, its fields, and a remove control. #} {# @prop form:text | description:"The row's Django form." #} {# @prop label:text | description:"Row label; defaults to the object's string, or its model name when unsaved." #} {# @prop class:text | description:"Extra classes on the root element." #} {# @prop first:boolean | default:False | description:"Suppresses the leading hairline for the first row." #} {# @prop can-delete:boolean | default:False | description:"Show the remove control, when this row also has a DELETE field." #} {# @prop layout:select['stacked','tabular'] | default:"stacked" | description:"Row presentation; tabular aligns fields to the set's column grid from sm up." #} {# @prop grid-style:text | description:"Column-track CSS passed down from the set, for tabular layout." #} {# @prop remove-label:text | default:"Remove" | description:"Accessible name for the remove control." #} {% comment %} The rule leads rather than trails so a removed row takes its own separator with it. `first` defaults False so a row cloned from the empty-form template - always appended after the others - still gets one. {% endcomment %} {% load i18n crispy_forms_filters mvp %} {% if form %} {% comment %} Removed is hidden, never detached, so removal survives an invalid resubmit. See docs/adr/0004-row-removal-uses-the-delete-flag-and-never-reindexes.md. {% endcomment %}
{% if not first %}{% endif %} {% firstof label form|formset_row_label as row_label %} {% comment %} Requires BOTH `can-delete` and a DELETE field on this row: under `can_delete_extra=False` Django withholds DELETE from extra forms even though `formset.can_delete` stays True on the set. {% endcomment %} {% if row_label or can_delete and form.DELETE %} {# `mb-1` replaces the row's own spacing under the header, so it sits with its fields. #}
{{ row_label }} {% if can_delete and form.DELETE %} {% comment %} `group-focus-within`, not hover alone, or the control is unreachable by keyboard - its icon-only text is the accessible name. {% endcomment %} {% endif %}
{% endif %} {{ form|as_crispy_errors }} {% for field in form.hidden_fields %}{{ field }}{% endfor %} {% comment %} The grid wrapper opens and closes conditionally rather than the field loop being written out twice. djlint cannot pair tags across a branch. {% endcomment %} {# djlint:off #} {% comment %} `[&_p.label]:hidden` drops the field's own help text from sm up: the set's heading row already carries one copy per column. A field's error messages are paragraphs too, with no class, so they stay. {% endcomment %} {% if layout == "tabular" %}
{% endif %} {% for field in form.visible_fields %} {% if field.name == "DELETE" %} {% elif layout == "tabular" %} {{ field|as_crispy_cell }} {% else %} {{ field|as_crispy_field }} {% endif %} {% endfor %} {% comment %} Trailing column is present on every row of a set that can delete, so columns line up whether or not this row has a DELETE field to record. {% endcomment %} {% if layout == "tabular" %} {% if can_delete %} {% endif %}
{% endif %} {# djlint:on #}
{% endif %}