+
+ {% for field in row.items %}
{% render_field_with_aria field has_helptext=True %}
{% trans field.label %}
@@ -39,13 +39,21 @@
{% endfor %}
+ {% if row.help_text %}
+ {# Shared help text rendered beneath the entire set of inline fields #}
+
+ {% endif %}
- {% elif layout == 'tabs' %}
+ {% elif row.layout == 'tabs' %}
{# Tabbed groups of fields #}
- {% for tab in items %}
+ {% for tab in row.items %}
{% for field in tab.fields %}
{% render_field field %}
diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py
index 4937b4a1f..a8532e46c 100644
--- a/netbox/utilities/templatetags/form_helpers.py
+++ b/netbox/utilities/templatetags/form_helpers.py
@@ -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 {
diff --git a/netbox/utilities/tests/test_templatetags.py b/netbox/utilities/tests/test_templatetags.py
index 7da38f163..02bd27daf 100644
--- a/netbox/utilities/tests/test_templatetags.py
+++ b/netbox/utilities/tests/test_templatetags.py
@@ -346,3 +346,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)