Merge pull request #22759 from netbox-community/22757-InlineFields-help-text

Closes #22757: Extend InlineFields to support an arbitrary help text
This commit is contained in:
bctiemann 2026-07-23 13:57:43 -04:00 committed by GitHub
commit d4670266d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 60 additions and 20 deletions

View File

@ -47,10 +47,12 @@ class InlineFields:
Parameters:
fields: An iterable of form field names
label: The label text to render for the row (optional)
help_text: Explanatory text rendered beneath the entire set of fields (optional)
"""
def __init__(self, *fields, label=None):
def __init__(self, *fields, label=None, help_text=None):
self.fields = fields
self.label = label
self.help_text = help_text
class TabbedGroups:

View File

@ -6,28 +6,28 @@
<h2 class="col-9 offset-3">{{ heading }}</h2>
</div>
{% endif %}
{% for layout, title, items in rows %}
{% for row in rows %}
{% if layout == 'field' %}
{% if row.layout == 'field' %}
{# Single form field #}
{% render_field items.0 %}
{% render_field row.items.0 %}
{% elif layout == 'attribute' %}
{% elif row.layout == 'attribute' %}
{# A static attribute of the form's instance #}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end required">{{ title }}</label>
<label class="col-sm-3 col-form-label text-lg-end required">{{ row.title }}</label>
<div class="col">
<div class="form-control-plaintext">
{{ items.0|linkify }}
{{ row.items.0|linkify }}
</div>
</div>
</div>
{% elif layout == 'inline' %}
{% elif row.layout == 'inline' %}
{# Multiple form fields on the same line #}
<div class="row mb-3"{% if title %} role="group" aria-label="{{ title }}"{% endif %}>
<label class="col col-3 col-form-label text-lg-end{% if items|any_required %} required{% endif %}">{{ title|default:'' }}</label>
{% for field in items %}
<div class="row{% if not row.help_text %} mb-3{% endif %}"{% if row.title %} role="group" aria-label="{{ row.title }}"{% endif %}>
<label class="col col-3 col-form-label text-lg-end{% if row.items|any_required %} required{% endif %}">{{ row.title|default:'' }}</label>
{% for field in row.items %}
<div class="col mb-1">
{% render_field_with_aria field has_helptext=True %}
<div class="form-text" id="{{ field.auto_id }}_helptext">{% trans field.label %}</div>
@ -39,13 +39,21 @@
</div>
{% endfor %}
</div>
{% if row.help_text %}
{# Shared help text rendered beneath the entire set of inline fields #}
<div class="row mb-3">
<div class="col offset-3">
<span class="form-text">{{ row.help_text|safe }}</span>
</div>
</div>
{% endif %}
{% elif layout == 'tabs' %}
{% elif row.layout == 'tabs' %}
{# Tabbed groups of fields #}
<div class="row">
<div class="col offset-3">
<ul class="nav nav-pills mb-1" role="tablist">
{% for tab in items %}
{% for tab in row.items %}
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="{{ tab.id }}_tab" data-bs-toggle="tab" aria-controls="{{ tab.id }}" aria-selected="{% if tab.active %}true{% else %}false{% endif %}" data-bs-target="#{{ tab.id }}" class="nav-link {% if tab.active %}active{% endif %}">
{% trans tab.title %}
@ -56,7 +64,7 @@
</div>
</div>
<div class="tab-content p-0 border-0">
{% for tab in items %}
{% for tab in row.items %}
<div class="tab-pane {% if tab.active %}active{% endif %}" id="{{ tab.id }}" role="tabpanel" aria-labelledby="{{ tab.id }}_tab">
{% for field in tab.fields %}
{% render_field field %}

View File

@ -1,4 +1,6 @@
import warnings
from collections.abc import Sequence
from typing import Any, NamedTuple
from django import forms, template
from django.conf import settings
@ -20,6 +22,17 @@ __all__ = (
register = template.Library()
class FieldsetRow(NamedTuple):
"""
A single row within a rendered fieldset. `layout` determines how the row's items are
rendered by the template (e.g. 'field', 'inline', 'tabs', 'attribute').
"""
layout: str
items: Sequence
title: Any = None
help_text: Any = None
#
# Filters
#
@ -131,7 +144,7 @@ def render_fieldset(form, fieldset):
form[name] for name in item.fields if name in form.fields
]
rows.append(
('inline', item.label, fields)
FieldsetRow('inline', fields, title=item.label, help_text=item.help_text)
)
# Tabbed groups of fields
@ -148,28 +161,28 @@ def render_fieldset(form, fieldset):
if not any(tab['active'] for tab in tabs):
tabs[0]['active'] = True
rows.append(
('tabs', None, tabs)
FieldsetRow('tabs', tabs)
)
elif type(item) is M2MAddRemoveFields:
if item.name in form.fields:
# Simple mode: render a single multi-select field
rows.append(
('field', None, [form[item.name]])
FieldsetRow('field', [form[item.name]])
)
else:
# Add/remove mode: render separate add and remove fields
for field_name in (f'add_{item.name}', f'remove_{item.name}'):
if field_name in form.fields:
rows.append(
('field', None, [form[field_name]])
FieldsetRow('field', [form[field_name]])
)
elif type(item) is ObjectAttribute:
value = getattr(form.instance, item.name)
label = value._meta.verbose_name if hasattr(value, '_meta') else item.name
rows.append(
('attribute', label.title(), [value])
FieldsetRow('attribute', [value], title=label.title())
)
# A single form field
@ -179,7 +192,7 @@ def render_fieldset(form, fieldset):
if field.name in getattr(form, 'nullable_fields', []):
field._nullable = True
rows.append(
('field', None, [field])
FieldsetRow('field', [field])
)
return {

View File

@ -372,3 +372,20 @@ class RenderFieldsetInlineRequiredTestCase(TestCase):
)
html = self._render(fieldset)
self.assertNotIn('col-form-label text-lg-end required', html)
def test_inline_help_text_rendered(self):
fieldset = FieldSet(
InlineFields('optional_field', 'another_optional', label='Combined', help_text='Shared guidance'),
)
html = self._render(fieldset)
# The shared help text is rendered in its own row (col offset-3) beneath the fields
self.assertIn('Shared guidance', html)
self.assertIn('col offset-3', html)
def test_inline_help_text_omitted_when_not_provided(self):
fieldset = FieldSet(
InlineFields('optional_field', 'another_optional', label='Combined'),
)
html = self._render(fieldset)
# With no help text, the shared help-text row (col offset-3) must not be rendered
self.assertNotIn('col offset-3', html)