From 00dc86b3ee748e535029ad9f52b64f06e2c67f3b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Jul 2026 10:09:25 -0400 Subject: [PATCH] Closes #22757: Extend InlineFields to support an arbitrary help text --- netbox/utilities/forms/rendering.py | 4 ++- .../form_helpers/render_fieldset.html | 34 ++++++++++++------- netbox/utilities/templatetags/form_helpers.py | 25 ++++++++++---- netbox/utilities/tests/test_templatetags.py | 17 ++++++++++ 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/netbox/utilities/forms/rendering.py b/netbox/utilities/forms/rendering.py index 66014c3a1..25f4a22dc 100644 --- a/netbox/utilities/forms/rendering.py +++ b/netbox/utilities/forms/rendering.py @@ -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: diff --git a/netbox/utilities/templates/form_helpers/render_fieldset.html b/netbox/utilities/templates/form_helpers/render_fieldset.html index 1ae1b8097..f497416c0 100644 --- a/netbox/utilities/templates/form_helpers/render_fieldset.html +++ b/netbox/utilities/templates/form_helpers/render_fieldset.html @@ -6,28 +6,28 @@

{{ heading }}

{% 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 #}
- +
- {{ items.0|linkify }} + {{ row.items.0|linkify }}
- {% elif layout == 'inline' %} + {% elif row.layout == 'inline' %} {# Multiple form fields on the same line #} -
- - {% for field in items %} +
+ + {% 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 #} +
+
+ {{ row.help_text|safe }} +
+
+ {% 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)