From 3ccdf4d53c0904a71719f83a1f2001715b525cef Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Mon, 1 Jun 2026 14:18:59 -0400 Subject: [PATCH 1/9] feat(ui): Support partial fieldset re-rendering via HTMXSelect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `id` parameter to `FieldSet` and a matching `hx_fieldset_id` parameter to `HTMXSelect`. When both are set, the widget emits `hx-select` and retargets `hx-target` to the named fieldset div rather than `#form_fields`, eliminating the full-form flash on large forms. The server always returns the complete form HTML; `hx-select` extracts only the target element on the client side, so no view or template changes are required. `hx-include` remains `#form_fields` so all field values are submitted and dependent-field resolution works correctly. Wires up `InterfaceForm` as the initial case: the 802.1Q Mode field now refreshes only the 802.1Q Switching fieldset. Closes #15165 partially — CableForm and ModuleTypeForm are candidates for a follow-up. Co-Authored-By: Claude Sonnet 4.6 --- netbox/dcim/forms/model_forms.py | 5 +++-- netbox/utilities/forms/rendering.py | 5 ++++- netbox/utilities/forms/widgets/select.py | 6 ++++-- .../utilities/templates/form_helpers/render_fieldset.html | 2 +- netbox/utilities/templatetags/form_helpers.py | 1 + 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 9672cc54b95..ba3eee802f9 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1645,7 +1645,8 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): FieldSet('poe_mode', 'poe_type', name=_('PoE')), FieldSet( 'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', - name=_('802.1Q Switching') + name=_('802.1Q Switching'), + id='dot1q-switching', ), FieldSet( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', @@ -1666,7 +1667,7 @@ class Meta: 'speed': NumberWithOptions( options=InterfaceSpeedChoices ), - 'mode': HTMXSelect(), + 'mode': HTMXSelect(hx_fieldset_id='dot1q-switching'), } labels = { 'mode': '802.1Q Mode', diff --git a/netbox/utilities/forms/rendering.py b/netbox/utilities/forms/rendering.py index c7bb324d899..04922b15af7 100644 --- a/netbox/utilities/forms/rendering.py +++ b/netbox/utilities/forms/rendering.py @@ -25,10 +25,13 @@ class FieldSet: Parameters: items: An iterable of items to be rendered (one per row) name: The fieldset's name, displayed as a heading (optional) + id: An HTML id for the rendered fieldset div, enabling HTMX partial swaps (optional). + Must be a valid CSS identifier: start with a letter, use only letters, digits, hyphens, underscores. """ - def __init__(self, *items, name=None): + def __init__(self, *items, name=None, id=None): self.items = items self.name = name + self.id = id class InlineFields: diff --git a/netbox/utilities/forms/widgets/select.py b/netbox/utilities/forms/widgets/select.py index 7297f483f3e..1474555e932 100644 --- a/netbox/utilities/forms/widgets/select.py +++ b/netbox/utilities/forms/widgets/select.py @@ -61,15 +61,17 @@ class HTMXSelect(forms.Select): """ Selection widget that will re-generate the HTML form upon the selection of a new option. """ - def __init__(self, method='get', hx_url='.', hx_target_id='form_fields', attrs=None, **kwargs): + def __init__(self, method='get', hx_url='.', hx_target_id='form_fields', hx_fieldset_id=None, attrs=None, **kwargs): method = method.lower() if method not in ('delete', 'get', 'patch', 'post', 'put'): raise ValueError(f"Unsupported HTTP method: {method}") _attrs = { f'hx-{method}': hx_url, 'hx-include': f'#{hx_target_id}', - 'hx-target': f'#{hx_target_id}', + 'hx-target': f'#{hx_fieldset_id}' if hx_fieldset_id else f'#{hx_target_id}', } + if hx_fieldset_id: + _attrs['hx-select'] = f'#{hx_fieldset_id}' if attrs: _attrs.update(attrs) diff --git a/netbox/utilities/templates/form_helpers/render_fieldset.html b/netbox/utilities/templates/form_helpers/render_fieldset.html index 1821a3cb70e..d588228e95e 100644 --- a/netbox/utilities/templates/form_helpers/render_fieldset.html +++ b/netbox/utilities/templates/form_helpers/render_fieldset.html @@ -1,6 +1,6 @@ {% load i18n %} {% load form_helpers %} -
+
{% if heading %}

{{ heading }}

diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index 156bd72c294..3b9660a861c 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -113,6 +113,7 @@ def render_fieldset(form, fieldset): return { 'heading': fieldset.name, + 'fieldset_id': getattr(fieldset, 'id', None), 'rows': rows, } From 520d0e7f5d49157abd80b387319d5556a0bbb3ee Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Mon, 1 Jun 2026 14:27:08 -0400 Subject: [PATCH 2/9] feat(ui): Wire partial fieldset re-rendering to CableForm and ModuleTypeForm Extends the HTMXSelect partial-swap pattern from InterfaceForm to two more forms: - CableForm: A Side and B Side termination selects now each target their own fieldset div (cable-side-a / cable-side-b) in cable_edit.html, so changing one side type no longer re-renders the other side. - ModuleTypeForm: the Profile selector targets the Profile and Attributes fieldset, which holds the dynamically generated attribute fields. Partial swap works correctly here because hx-include still sends the full form, so the server builds the complete attr_fields list before the client extracts just the target fieldset from the response. Co-Authored-By: Claude Sonnet 4.6 --- netbox/dcim/forms/model_forms.py | 8 ++++---- netbox/templates/dcim/htmx/cable_edit.html | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index ba3eee802f9..9f091edc638 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -475,7 +475,7 @@ class ModuleTypeForm(PrimaryModelForm): queryset=ModuleTypeProfile.objects.all(), label=_('Profile'), required=False, - widget=HTMXSelect() + widget=HTMXSelect(hx_fieldset_id='profile-attributes') ) manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), @@ -487,7 +487,7 @@ def fieldsets(self): return [ FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')), - FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes')) + FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'), id='profile-attributes') ] class Meta: @@ -872,13 +872,13 @@ class CableForm(TenancyForm, PrimaryModelForm): a_terminations_type = forms.ChoiceField( choices=get_termination_type_choices, required=False, - widget=HTMXSelect(), + widget=HTMXSelect(hx_fieldset_id='cable-side-a'), label=_('Type') ) b_terminations_type = forms.ChoiceField( choices=get_termination_type_choices, required=False, - widget=HTMXSelect(), + widget=HTMXSelect(hx_fieldset_id='cable-side-b'), label=_('Type') ) bundle = DynamicModelChoiceField( diff --git a/netbox/templates/dcim/htmx/cable_edit.html b/netbox/templates/dcim/htmx/cable_edit.html index 056c1679cca..331dac80894 100644 --- a/netbox/templates/dcim/htmx/cable_edit.html +++ b/netbox/templates/dcim/htmx/cable_edit.html @@ -8,7 +8,7 @@ {% endfor %} {# A side termination #} -
+

{% trans "A Side" %}

@@ -28,7 +28,7 @@

{% trans "A Side" %}

{# B side termination #} -
+

{% trans "B Side" %}

From c5157d733c95372dc5e2b75aff682de5eb2a9aa8 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Mon, 1 Jun 2026 14:42:58 -0400 Subject: [PATCH 3/9] feat(ui): Wire partial fieldset re-rendering across remaining HTMXSelect forms Applies the hx_fieldset_id / FieldSet id pattern to all remaining model forms where partial swap is safe (i.e. the trigger field and all fields that change in response live within a single fieldset): - ScopedForm mixin: scope_type targets fieldset id=scope; all three consumers (ClusterForm, WirelessLANForm, PrefixForm) annotated - VLANGroupForm: scope_type / Scope fieldset - ServiceForm: parent_object_type / Application Service fieldset - VMInterfaceForm: mode / 802.1Q Switching fieldset (mirrors InterfaceForm) - CircuitTerminationForm: termination_type / Circuit Termination fieldset - CircuitGroupAssignmentForm: member_type / Group Assignment fieldset - VirtualCircuitTerminationForm: role / single fieldset - TunnelCreateForm: termination1_type and termination2_type each target their own First/Second Termination fieldsets independently - TunnelTerminationForm: type / single fieldset - EventRuleForm: action_type / Action fieldset Three forms intentionally left on full-form swap: - CustomFieldForm: type change adds/removes Validation/Related Object/ Choices fieldsets dynamically; partial swap would miss those additions - DataSourceForm: type change adds/removes Backend Parameters fieldset - VirtualMachineForm: type change populates defaults across both the Virtual Machine and Resources fieldsets Co-Authored-By: Claude Sonnet 4.6 --- netbox/circuits/forms/model_forms.py | 16 ++++++++++------ netbox/dcim/forms/mixins.py | 2 +- netbox/extras/forms/model_forms.py | 4 ++-- netbox/ipam/forms/model_forms.py | 11 ++++++----- netbox/virtualization/forms/model_forms.py | 7 ++++--- netbox/vpn/forms/model_forms.py | 12 ++++++------ netbox/wireless/forms/model_forms.py | 2 +- 7 files changed, 30 insertions(+), 24 deletions(-) diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 446ec0eedd5..a052d25013a 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -194,7 +194,7 @@ class CircuitTerminationForm(NetBoxModelForm): ) termination_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), - widget=HTMXSelect(), + widget=HTMXSelect(hx_fieldset_id='circuit-termination'), required=False, label=_('Termination type') ) @@ -210,7 +210,8 @@ class CircuitTerminationForm(NetBoxModelForm): FieldSet( 'circuit', 'term_side', 'description', 'tags', 'termination_type', 'termination', - 'mark_connected', name=_('Circuit Termination') + 'mark_connected', name=_('Circuit Termination'), + id='circuit-termination', ), FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')), ) @@ -281,7 +282,7 @@ class CircuitGroupAssignmentForm(NetBoxModelForm): ) member_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS), - widget=HTMXSelect(), + widget=HTMXSelect(hx_fieldset_id='circuit-group-assignment'), required=False, label=_('Circuit type') ) @@ -294,7 +295,10 @@ class CircuitGroupAssignmentForm(NetBoxModelForm): ) fieldsets = ( - FieldSet('group', 'member_type', 'member', 'priority', 'tags', name=_('Group Assignment')), + FieldSet( + 'group', 'member_type', 'member', 'priority', 'tags', + name=_('Group Assignment'), id='circuit-group-assignment', + ), ) class Meta: @@ -385,7 +389,7 @@ class VirtualCircuitTerminationForm(NetBoxModelForm): ) role = forms.ChoiceField( choices=VirtualCircuitTerminationRoleChoices, - widget=HTMXSelect(), + widget=HTMXSelect(hx_fieldset_id='virtual-circuit-termination'), label=_('Role') ) interface = DynamicModelChoiceField( @@ -402,7 +406,7 @@ class VirtualCircuitTerminationForm(NetBoxModelForm): ) fieldsets = ( - FieldSet('virtual_circuit', 'role', 'interface', 'description', 'tags'), + FieldSet('virtual_circuit', 'role', 'interface', 'description', 'tags', id='virtual-circuit-termination'), ) class Meta: diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index 709d6122235..35207f0ed7b 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -27,7 +27,7 @@ class ScopedForm(forms.Form): scope_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES), - widget=HTMXSelect(), + widget=HTMXSelect(hx_fieldset_id='scope'), required=False, label=_('Scope type') ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index f1b23e0d54e..5a91542bda5 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -556,7 +556,7 @@ class EventRuleForm(OwnerMixin, NetBoxModelForm): fieldsets = ( FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')), FieldSet('event_types', 'conditions', name=_('Triggers')), - FieldSet('action_type', 'action_choice', 'action_data', name=_('Action')), + FieldSet('action_type', 'action_choice', 'action_data', name=_('Action'), id='event-rule-action'), ) class Meta: @@ -567,7 +567,7 @@ class Meta: ) widgets = { 'conditions': forms.Textarea(attrs={'class': 'font-monospace'}), - 'action_type': HTMXSelect(), + 'action_type': HTMXSelect(hx_fieldset_id='event-rule-action'), 'action_object_type': forms.HiddenInput, 'action_object_id': forms.HiddenInput, } diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index bffe7a1eb99..8c650b56b1b 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -229,7 +229,7 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm): FieldSet( 'prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix') ), - FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('scope_type', 'scope', name=_('Scope'), id='scope'), FieldSet('vlan', name=_('VLAN Assignment')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -625,7 +625,7 @@ class VLANGroupForm(TenancyForm, OrganizationalModelForm): ) scope_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - widget=HTMXSelect(), + widget=HTMXSelect(hx_fieldset_id='scope'), required=False, label=_('Scope type') ) @@ -640,7 +640,7 @@ class VLANGroupForm(TenancyForm, OrganizationalModelForm): fieldsets = ( FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')), FieldSet('vid_ranges', name=_('Child VLANs')), - FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('scope_type', 'scope', name=_('Scope'), id='scope'), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -780,7 +780,7 @@ class Meta: class ServiceForm(PrimaryModelForm): parent_object_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS), - widget=HTMXSelect(), + widget=HTMXSelect(hx_fieldset_id='service'), required=True, label=_('Parent type') ) @@ -809,7 +809,8 @@ class ServiceForm(PrimaryModelForm): FieldSet( 'parent_object_type', 'parent', 'name', InlineFields('protocol', 'ports', label=_('Port(s)')), - 'ipaddresses', 'description', 'tags', name=_('Application Service') + 'ipaddresses', 'description', 'tags', name=_('Application Service'), + id='service', ), ) diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 168839a2595..824a17b15dc 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -74,7 +74,7 @@ class ClusterForm(TenancyForm, ScopedForm, PrimaryModelForm): fieldsets = ( FieldSet('name', 'type', 'group', 'status', 'description', 'tags', name=_('Cluster')), - FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('scope_type', 'scope', name=_('Scope'), id='scope'), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -452,7 +452,8 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm): FieldSet('parent', 'bridge', name=_('Related Interfaces')), FieldSet( 'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', - name=_('802.1Q Switching') + name=_('802.1Q Switching'), + id='dot1q-switching', ), ) @@ -467,7 +468,7 @@ class Meta: 'mode': _('802.1Q Mode'), } widgets = { - 'mode': HTMXSelect(), + 'mode': HTMXSelect(hx_fieldset_id='dot1q-switching'), } diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 82bc79e4e81..e48454dfb95 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -78,7 +78,7 @@ class TunnelCreateForm(TunnelForm): termination1_type = forms.ChoiceField( choices=TunnelTerminationTypeChoices, required=False, - widget=HTMXSelect(), + widget=HTMXSelect(hx_fieldset_id='tunnel-termination1'), label=_('Type') ) termination1_parent = DynamicModelChoiceField( @@ -113,7 +113,7 @@ class TunnelCreateForm(TunnelForm): termination2_type = forms.ChoiceField( choices=TunnelTerminationTypeChoices, required=False, - widget=HTMXSelect(), + widget=HTMXSelect(hx_fieldset_id='tunnel-termination2'), label=_('Type') ) termination2_parent = DynamicModelChoiceField( @@ -145,10 +145,10 @@ class TunnelCreateForm(TunnelForm): FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet( 'termination1_role', 'termination1_type', 'termination1_parent', 'termination1_termination', - 'termination1_outside_ip', name=_('First Termination')), + 'termination1_outside_ip', name=_('First Termination'), id='tunnel-termination1'), FieldSet( 'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_termination', - 'termination2_outside_ip', name=_('Second Termination')), + 'termination2_outside_ip', name=_('Second Termination'), id='tunnel-termination2'), ) def __init__(self, *args, initial=None, **kwargs): @@ -225,7 +225,7 @@ class TunnelTerminationForm(NetBoxModelForm): ) type = forms.ChoiceField( choices=TunnelTerminationTypeChoices, - widget=HTMXSelect(), + widget=HTMXSelect(hx_fieldset_id='tunnel-termination'), label=_('Type') ) parent = DynamicModelChoiceField( @@ -250,7 +250,7 @@ class TunnelTerminationForm(NetBoxModelForm): ) fieldsets = ( - FieldSet('tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags'), + FieldSet('tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags', id='tunnel-termination'), ) class Meta: diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 0cd107ba6e4..56f17008b3b 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -52,7 +52,7 @@ class WirelessLANForm(ScopedForm, TenancyForm, PrimaryModelForm): fieldsets = ( FieldSet('ssid', 'group', 'vlan', 'status', 'description', 'tags', name=_('Wireless LAN')), - FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('scope_type', 'scope', name=_('Scope'), id='scope'), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) From 50879ca2e964f3eb4a5009f9bdb06a71819c49ba Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Mon, 1 Jun 2026 14:45:44 -0400 Subject: [PATCH 4/9] docs(ui): Explain why three HTMXSelect fields keep full-form swap CustomFieldForm, DataSourceForm, and VirtualMachineForm each have a type/kind selector that either adds/removes whole fieldsets dynamically or populates fields across multiple fieldsets. Partial fieldset swap would silently miss those changes, so they intentionally stay on the full hx-target='#form_fields' path. Co-Authored-By: Claude Sonnet 4.6 --- netbox/core/forms/model_forms.py | 1 + netbox/extras/forms/model_forms.py | 3 ++- netbox/virtualization/forms/model_forms.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py index 074ea849833..8873456b227 100644 --- a/netbox/core/forms/model_forms.py +++ b/netbox/core/forms/model_forms.py @@ -29,6 +29,7 @@ class DataSourceForm(PrimaryModelForm): type = forms.ChoiceField( choices=get_data_backend_choices, + # No hx_fieldset_id: changing type adds/removes the Backend Parameters fieldset entirely. widget=HTMXSelect() ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 5a91542bda5..ee8e643a7e2 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -107,7 +107,8 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Mimic HTMXSelect() + # Mimic HTMXSelect() — no hx_fieldset_id because changing type adds/removes + # Validation, Related Object, and Choices fieldsets dynamically. self.fields['type'].widget.attrs.update({ 'hx-get': '.', 'hx-include': '#form_fields', diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 824a17b15dc..e779069aa91 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -198,6 +198,8 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm): label=_('Type'), queryset=VirtualMachineType.objects.all(), required=False, + # No hx_fieldset_id: type change populates defaults across both the Virtual Machine + # and Resources fieldsets, so a single-fieldset partial swap would miss half the update. widget=HTMXSelect(), ) site = DynamicModelChoiceField( From c648d5e755edcb55baf52dcc0fe3853b6c2e3b8e Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Mon, 1 Jun 2026 15:18:03 -0400 Subject: [PATCH 5/9] Fix hx-swap nesting, rename FieldSet.id to fieldset_id, add unit tests - HTMXSelect: add hx-swap=outerHTML when hx_fieldset_id is set, preventing progressive div nesting on repeated selections - FieldSet: rename id parameter to fieldset_id to avoid shadowing builtins; update all call sites across circuits, dcim, extras, ipam, virtualization, vpn, wireless - templatetags: use fieldset.fieldset_id directly instead of getattr fallback - ScopedForm: add comment documenting the fieldset_id contract for consumers - Add FieldSetTestCase and HTMXSelectTestCase unit tests Co-Authored-By: Claude Sonnet 4.6 --- netbox/circuits/forms/model_forms.py | 9 +++-- netbox/dcim/forms/mixins.py | 1 + netbox/dcim/forms/model_forms.py | 4 +-- netbox/extras/forms/model_forms.py | 2 +- netbox/ipam/forms/model_forms.py | 6 ++-- netbox/utilities/forms/rendering.py | 6 ++-- netbox/utilities/forms/widgets/select.py | 1 + netbox/utilities/templatetags/form_helpers.py | 2 +- netbox/utilities/tests/test_forms.py | 34 ++++++++++++++++++- netbox/virtualization/forms/model_forms.py | 4 +-- netbox/vpn/forms/model_forms.py | 9 +++-- netbox/wireless/forms/model_forms.py | 2 +- 12 files changed, 60 insertions(+), 20 deletions(-) diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index a052d25013a..6e17b54fcb1 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -211,7 +211,7 @@ class CircuitTerminationForm(NetBoxModelForm): 'circuit', 'term_side', 'description', 'tags', 'termination_type', 'termination', 'mark_connected', name=_('Circuit Termination'), - id='circuit-termination', + fieldset_id='circuit-termination', ), FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')), ) @@ -297,7 +297,7 @@ class CircuitGroupAssignmentForm(NetBoxModelForm): fieldsets = ( FieldSet( 'group', 'member_type', 'member', 'priority', 'tags', - name=_('Group Assignment'), id='circuit-group-assignment', + name=_('Group Assignment'), fieldset_id='circuit-group-assignment', ), ) @@ -406,7 +406,10 @@ class VirtualCircuitTerminationForm(NetBoxModelForm): ) fieldsets = ( - FieldSet('virtual_circuit', 'role', 'interface', 'description', 'tags', id='virtual-circuit-termination'), + FieldSet( + 'virtual_circuit', 'role', 'interface', 'description', 'tags', + fieldset_id='virtual-circuit-termination', + ), ) class Meta: diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index 35207f0ed7b..a1dea758346 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -27,6 +27,7 @@ class ScopedForm(forms.Form): scope_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES), + # hx_fieldset_id='scope' — all ScopedForm consumers must declare a FieldSet with fieldset_id='scope' widget=HTMXSelect(hx_fieldset_id='scope'), required=False, label=_('Scope type') diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 9f091edc638..33b72af59e9 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -487,7 +487,7 @@ def fieldsets(self): return [ FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')), - FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'), id='profile-attributes') + FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'), fieldset_id='profile-attributes') ] class Meta: @@ -1646,7 +1646,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): FieldSet( 'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching'), - id='dot1q-switching', + fieldset_id='dot1q-switching', ), FieldSet( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index ee8e643a7e2..1b944012d2b 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -557,7 +557,7 @@ class EventRuleForm(OwnerMixin, NetBoxModelForm): fieldsets = ( FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')), FieldSet('event_types', 'conditions', name=_('Triggers')), - FieldSet('action_type', 'action_choice', 'action_data', name=_('Action'), id='event-rule-action'), + FieldSet('action_type', 'action_choice', 'action_data', name=_('Action'), fieldset_id='event-rule-action'), ) class Meta: diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 8c650b56b1b..79760415c30 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -229,7 +229,7 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm): FieldSet( 'prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix') ), - FieldSet('scope_type', 'scope', name=_('Scope'), id='scope'), + FieldSet('scope_type', 'scope', name=_('Scope'), fieldset_id='scope'), FieldSet('vlan', name=_('VLAN Assignment')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -640,7 +640,7 @@ class VLANGroupForm(TenancyForm, OrganizationalModelForm): fieldsets = ( FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')), FieldSet('vid_ranges', name=_('Child VLANs')), - FieldSet('scope_type', 'scope', name=_('Scope'), id='scope'), + FieldSet('scope_type', 'scope', name=_('Scope'), fieldset_id='scope'), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -810,7 +810,7 @@ class ServiceForm(PrimaryModelForm): 'parent_object_type', 'parent', 'name', InlineFields('protocol', 'ports', label=_('Port(s)')), 'ipaddresses', 'description', 'tags', name=_('Application Service'), - id='service', + fieldset_id='service', ), ) diff --git a/netbox/utilities/forms/rendering.py b/netbox/utilities/forms/rendering.py index 04922b15af7..9b59982fd88 100644 --- a/netbox/utilities/forms/rendering.py +++ b/netbox/utilities/forms/rendering.py @@ -25,13 +25,13 @@ class FieldSet: Parameters: items: An iterable of items to be rendered (one per row) name: The fieldset's name, displayed as a heading (optional) - id: An HTML id for the rendered fieldset div, enabling HTMX partial swaps (optional). + fieldset_id: An HTML id for the rendered fieldset div, enabling HTMX partial swaps (optional). Must be a valid CSS identifier: start with a letter, use only letters, digits, hyphens, underscores. """ - def __init__(self, *items, name=None, id=None): + def __init__(self, *items, name=None, fieldset_id=None): self.items = items self.name = name - self.id = id + self.fieldset_id = fieldset_id class InlineFields: diff --git a/netbox/utilities/forms/widgets/select.py b/netbox/utilities/forms/widgets/select.py index 1474555e932..8f2111cf033 100644 --- a/netbox/utilities/forms/widgets/select.py +++ b/netbox/utilities/forms/widgets/select.py @@ -72,6 +72,7 @@ def __init__(self, method='get', hx_url='.', hx_target_id='form_fields', hx_fiel } if hx_fieldset_id: _attrs['hx-select'] = f'#{hx_fieldset_id}' + _attrs['hx-swap'] = 'outerHTML' if attrs: _attrs.update(attrs) diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index 3b9660a861c..6f38986c59e 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -113,7 +113,7 @@ def render_fieldset(form, fieldset): return { 'heading': fieldset.name, - 'fieldset_id': getattr(fieldset, 'id', None), + 'fieldset_id': fieldset.fieldset_id, 'rows': rows, } diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index b29cbadf6a1..8bd2dc0d1fc 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -6,13 +6,14 @@ from utilities.forms.bulk_import import BulkImportForm from utilities.forms.fields.csv import CSVSelectWidget from utilities.forms.forms import BulkRenameForm +from utilities.forms.rendering import FieldSet from utilities.forms.utils import ( expand_alphanumeric_pattern, expand_ipnetwork_pattern, get_capacity_unit_label, get_field_value, ) -from utilities.forms.widgets.select import AvailableOptions, SelectedOptions +from utilities.forms.widgets.select import AvailableOptions, HTMXSelect, SelectedOptions class ExpandIPNetwork(TestCase): @@ -567,3 +568,34 @@ def test_si_label(self): def test_iec_label(self): self.assertEqual(get_capacity_unit_label(1024), 'MiB') + + +class FieldSetTestCase(TestCase): + + def test_fieldset_id_defaults_to_none(self): + fs = FieldSet('field1', 'field2', name='Test') + self.assertIsNone(fs.fieldset_id) + + def test_fieldset_id_stored(self): + fs = FieldSet('field1', 'field2', name='Test', fieldset_id='my-fieldset') + self.assertEqual(fs.fieldset_id, 'my-fieldset') + + +class HTMXSelectTestCase(TestCase): + + def test_default_targets_form_fields(self): + widget = HTMXSelect() + self.assertEqual(widget.attrs['hx-target'], '#form_fields') + self.assertEqual(widget.attrs['hx-include'], '#form_fields') + self.assertNotIn('hx-select', widget.attrs) + self.assertNotIn('hx-swap', widget.attrs) + + def test_hx_fieldset_id_sets_target_select_and_swap(self): + widget = HTMXSelect(hx_fieldset_id='my-fieldset') + self.assertEqual(widget.attrs['hx-target'], '#my-fieldset') + self.assertEqual(widget.attrs['hx-select'], '#my-fieldset') + self.assertEqual(widget.attrs['hx-swap'], 'outerHTML') + + def test_hx_fieldset_id_include_stays_on_form_fields(self): + widget = HTMXSelect(hx_fieldset_id='my-fieldset') + self.assertEqual(widget.attrs['hx-include'], '#form_fields') diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index e779069aa91..325c0665758 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -74,7 +74,7 @@ class ClusterForm(TenancyForm, ScopedForm, PrimaryModelForm): fieldsets = ( FieldSet('name', 'type', 'group', 'status', 'description', 'tags', name=_('Cluster')), - FieldSet('scope_type', 'scope', name=_('Scope'), id='scope'), + FieldSet('scope_type', 'scope', name=_('Scope'), fieldset_id='scope'), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -455,7 +455,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm): FieldSet( 'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching'), - id='dot1q-switching', + fieldset_id='dot1q-switching', ), ) diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index e48454dfb95..640d9b733a7 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -145,10 +145,10 @@ class TunnelCreateForm(TunnelForm): FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet( 'termination1_role', 'termination1_type', 'termination1_parent', 'termination1_termination', - 'termination1_outside_ip', name=_('First Termination'), id='tunnel-termination1'), + 'termination1_outside_ip', name=_('First Termination'), fieldset_id='tunnel-termination1'), FieldSet( 'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_termination', - 'termination2_outside_ip', name=_('Second Termination'), id='tunnel-termination2'), + 'termination2_outside_ip', name=_('Second Termination'), fieldset_id='tunnel-termination2'), ) def __init__(self, *args, initial=None, **kwargs): @@ -250,7 +250,10 @@ class TunnelTerminationForm(NetBoxModelForm): ) fieldsets = ( - FieldSet('tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags', id='tunnel-termination'), + FieldSet( + 'tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags', + fieldset_id='tunnel-termination', + ), ) class Meta: diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 56f17008b3b..5261b40a895 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -52,7 +52,7 @@ class WirelessLANForm(ScopedForm, TenancyForm, PrimaryModelForm): fieldsets = ( FieldSet('ssid', 'group', 'vlan', 'status', 'description', 'tags', name=_('Wireless LAN')), - FieldSet('scope_type', 'scope', name=_('Scope'), id='scope'), + FieldSet('scope_type', 'scope', name=_('Scope'), fieldset_id='scope'), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) From a9dcec10e1e2000cafda21069e3af0a9c7b0ce36 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Mon, 1 Jun 2026 15:52:36 -0400 Subject: [PATCH 6/9] Add DEBUG-mode guards for fieldset_id and ScopedForm contract - FieldSet.__init__: raise ValueError in DEBUG mode if fieldset_id is not a valid CSS identifier (must start with a letter) - ScopedForm.__init__: emit a warnings.warn in DEBUG mode when a subclass does not declare a FieldSet with fieldset_id='scope', preventing silent HTMX swap failures for future consumers - Add override_settings tests covering both the valid/invalid CSS identifier paths Co-Authored-By: Claude Sonnet 4.6 --- netbox/dcim/forms/mixins.py | 15 +++++++++++++++ netbox/utilities/forms/rendering.py | 6 ++++++ netbox/utilities/tests/test_forms.py | 12 +++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index a1dea758346..b8f07ba4dcb 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -1,4 +1,7 @@ +import warnings + from django import forms +from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import connection @@ -51,6 +54,18 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._set_scoped_values() + if settings.DEBUG: + has_scope_fieldset = any( + getattr(fs, 'fieldset_id', None) == 'scope' + for fs in getattr(self, 'fieldsets', []) + ) + if not has_scope_fieldset: + warnings.warn( + f"{self.__class__.__name__} uses ScopedForm but declares no " + "FieldSet with fieldset_id='scope'; HTMX partial swap will fail silently.", + stacklevel=2, + ) + def clean(self): super().clean() diff --git a/netbox/utilities/forms/rendering.py b/netbox/utilities/forms/rendering.py index 9b59982fd88..b5fb9bf01b1 100644 --- a/netbox/utilities/forms/rendering.py +++ b/netbox/utilities/forms/rendering.py @@ -1,7 +1,10 @@ import random +import re import string from functools import cached_property +from django.conf import settings + __all__ = ( 'FieldSet', 'InlineFields', @@ -29,6 +32,9 @@ class FieldSet: Must be a valid CSS identifier: start with a letter, use only letters, digits, hyphens, underscores. """ def __init__(self, *items, name=None, fieldset_id=None): + if fieldset_id is not None and settings.DEBUG: + if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', fieldset_id): + raise ValueError(f"fieldset_id {fieldset_id!r} is not a valid CSS identifier") self.items = items self.name = name self.fieldset_id = fieldset_id diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index 8bd2dc0d1fc..04790281b99 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -1,5 +1,5 @@ from django import forms -from django.test import TestCase +from django.test import TestCase, override_settings from dcim.models import Site from netbox.choices import ImportFormatChoices @@ -580,6 +580,16 @@ def test_fieldset_id_stored(self): fs = FieldSet('field1', 'field2', name='Test', fieldset_id='my-fieldset') self.assertEqual(fs.fieldset_id, 'my-fieldset') + @override_settings(DEBUG=True) + def test_fieldset_id_invalid_css_identifier_raises(self): + with self.assertRaises(ValueError): + FieldSet('field1', fieldset_id='123-bad') + + @override_settings(DEBUG=False) + def test_fieldset_id_invalid_css_identifier_ignored_outside_debug(self): + fs = FieldSet('field1', fieldset_id='123-bad') + self.assertEqual(fs.fieldset_id, '123-bad') + class HTMXSelectTestCase(TestCase): From 680555d39c01afa41c91c2bee5e83837ac65ff82 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Tue, 9 Jun 2026 14:19:46 -0400 Subject: [PATCH 7/9] =?UTF-8?q?Address=20Jeremy's=20review:=20rename=20fie?= =?UTF-8?q?ldset=5Fid=E2=86=92html=5Fid,=20split=20hx=5Ftarget=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FieldSet: rename fieldset_id parameter to html_id (less redundant, more generic — it is an HTML id attribute, not fieldset-specific). HTMXSelect: separate the two concerns that were conflated in hx_target_id: - hx_include_id (new name for old hx_target_id): the form container whose data is submitted with the HTMX request — always #form_fields so that the server receives the full form state for dependent-field resolution. - hx_target_id (new name for old hx_fieldset_id): the element to swap and hx-select from the response — defaults to hx_include_id (full swap) or can be set to a specific fieldset for partial swaps. This makes Jeremy's intuition correct: you can now pass just hx_target_id='dot1q-switching' to target a specific fieldset without accidentally scoping hx-include to that fieldset too. Update all call sites, templates, templatetags, and tests. Co-Authored-By: Claude Sonnet 4.6 --- netbox/circuits/forms/model_forms.py | 12 ++++---- netbox/dcim/forms/mixins.py | 8 +++--- netbox/dcim/forms/model_forms.py | 12 ++++---- netbox/extras/forms/model_forms.py | 4 +-- netbox/ipam/forms/model_forms.py | 10 +++---- .../dist/graphiql/js.cookie.min.js | 4 +-- netbox/utilities/forms/rendering.py | 12 ++++---- netbox/utilities/forms/widgets/select.py | 10 +++---- .../form_helpers/render_fieldset.html | 2 +- netbox/utilities/templatetags/form_helpers.py | 2 +- netbox/utilities/tests/test_forms.py | 28 +++++++++---------- netbox/virtualization/forms/model_forms.py | 6 ++-- netbox/vpn/forms/model_forms.py | 12 ++++---- netbox/wireless/forms/model_forms.py | 2 +- 14 files changed, 62 insertions(+), 62 deletions(-) diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 8c601265729..3c4b3ba3a22 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -194,7 +194,7 @@ class CircuitTerminationForm(NetBoxModelForm): ) termination_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), - widget=HTMXSelect(hx_fieldset_id='circuit-termination'), + widget=HTMXSelect(hx_target_id='circuit-termination'), required=False, label=_('Termination type') ) @@ -210,7 +210,7 @@ class CircuitTerminationForm(NetBoxModelForm): 'circuit', 'term_side', 'description', 'tags', 'termination_type', 'termination', 'mark_connected', name=_('Circuit Termination'), - fieldset_id='circuit-termination', + html_id='circuit-termination', ), FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')), ) @@ -293,7 +293,7 @@ class CircuitGroupAssignmentForm(NetBoxModelForm): ) member_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS), - widget=HTMXSelect(hx_fieldset_id='circuit-group-assignment'), + widget=HTMXSelect(hx_target_id='circuit-group-assignment'), required=False, label=_('Circuit type') ) @@ -308,7 +308,7 @@ class CircuitGroupAssignmentForm(NetBoxModelForm): fieldsets = ( FieldSet( 'group', 'member_type', 'member', 'priority', 'tags', - name=_('Group Assignment'), fieldset_id='circuit-group-assignment', + name=_('Group Assignment'), html_id='circuit-group-assignment', ), ) @@ -400,7 +400,7 @@ class VirtualCircuitTerminationForm(NetBoxModelForm): ) role = forms.ChoiceField( choices=VirtualCircuitTerminationRoleChoices, - widget=HTMXSelect(hx_fieldset_id='virtual-circuit-termination'), + widget=HTMXSelect(hx_target_id='virtual-circuit-termination'), label=_('Role') ) interface = DynamicModelChoiceField( @@ -419,7 +419,7 @@ class VirtualCircuitTerminationForm(NetBoxModelForm): fieldsets = ( FieldSet( 'virtual_circuit', 'role', 'interface', 'description', 'tags', - fieldset_id='virtual-circuit-termination', + html_id='virtual-circuit-termination', ), ) diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index b8f07ba4dcb..6ddbc01a198 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -30,8 +30,8 @@ class ScopedForm(forms.Form): scope_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES), - # hx_fieldset_id='scope' — all ScopedForm consumers must declare a FieldSet with fieldset_id='scope' - widget=HTMXSelect(hx_fieldset_id='scope'), + # hx_target_id='scope' — all ScopedForm consumers must declare a FieldSet with html_id='scope' + widget=HTMXSelect(hx_target_id='scope'), required=False, label=_('Scope type') ) @@ -56,13 +56,13 @@ def __init__(self, *args, **kwargs): if settings.DEBUG: has_scope_fieldset = any( - getattr(fs, 'fieldset_id', None) == 'scope' + getattr(fs, 'html_id', None) == 'scope' for fs in getattr(self, 'fieldsets', []) ) if not has_scope_fieldset: warnings.warn( f"{self.__class__.__name__} uses ScopedForm but declares no " - "FieldSet with fieldset_id='scope'; HTMX partial swap will fail silently.", + "FieldSet with html_id='scope'; HTMX partial swap will fail silently.", stacklevel=2, ) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 79533e29eff..7d06198b61f 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -475,7 +475,7 @@ class ModuleTypeForm(PrimaryModelForm): queryset=ModuleTypeProfile.objects.all(), label=_('Profile'), required=False, - widget=HTMXSelect(hx_fieldset_id='profile-attributes') + widget=HTMXSelect(hx_target_id='profile-attributes') ) manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), @@ -487,7 +487,7 @@ def fieldsets(self): return [ FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')), - FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'), fieldset_id='profile-attributes') + FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'), html_id='profile-attributes') ] class Meta: @@ -873,13 +873,13 @@ class CableForm(TenancyForm, PrimaryModelForm): a_terminations_type = forms.ChoiceField( choices=get_termination_type_choices, required=False, - widget=HTMXSelect(hx_fieldset_id='cable-side-a'), + widget=HTMXSelect(hx_target_id='cable-side-a'), label=_('Type') ) b_terminations_type = forms.ChoiceField( choices=get_termination_type_choices, required=False, - widget=HTMXSelect(hx_fieldset_id='cable-side-b'), + widget=HTMXSelect(hx_target_id='cable-side-b'), label=_('Type') ) bundle = DynamicModelChoiceField( @@ -1647,7 +1647,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): FieldSet( 'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching'), - fieldset_id='dot1q-switching', + html_id='dot1q-switching', ), FieldSet( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', @@ -1668,7 +1668,7 @@ class Meta: 'speed': NumberWithOptions( options=InterfaceSpeedChoices ), - 'mode': HTMXSelect(hx_fieldset_id='dot1q-switching'), + 'mode': HTMXSelect(hx_target_id='dot1q-switching'), } labels = { 'mode': '802.1Q Mode', diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 1b944012d2b..93343aff34a 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -557,7 +557,7 @@ class EventRuleForm(OwnerMixin, NetBoxModelForm): fieldsets = ( FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')), FieldSet('event_types', 'conditions', name=_('Triggers')), - FieldSet('action_type', 'action_choice', 'action_data', name=_('Action'), fieldset_id='event-rule-action'), + FieldSet('action_type', 'action_choice', 'action_data', name=_('Action'), html_id='event-rule-action'), ) class Meta: @@ -568,7 +568,7 @@ class Meta: ) widgets = { 'conditions': forms.Textarea(attrs={'class': 'font-monospace'}), - 'action_type': HTMXSelect(hx_fieldset_id='event-rule-action'), + 'action_type': HTMXSelect(hx_target_id='event-rule-action'), 'action_object_type': forms.HiddenInput, 'action_object_id': forms.HiddenInput, } diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 79760415c30..7864f7e46dc 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -229,7 +229,7 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm): FieldSet( 'prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix') ), - FieldSet('scope_type', 'scope', name=_('Scope'), fieldset_id='scope'), + FieldSet('scope_type', 'scope', name=_('Scope'), html_id='scope'), FieldSet('vlan', name=_('VLAN Assignment')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -625,7 +625,7 @@ class VLANGroupForm(TenancyForm, OrganizationalModelForm): ) scope_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - widget=HTMXSelect(hx_fieldset_id='scope'), + widget=HTMXSelect(hx_target_id='scope'), required=False, label=_('Scope type') ) @@ -640,7 +640,7 @@ class VLANGroupForm(TenancyForm, OrganizationalModelForm): fieldsets = ( FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')), FieldSet('vid_ranges', name=_('Child VLANs')), - FieldSet('scope_type', 'scope', name=_('Scope'), fieldset_id='scope'), + FieldSet('scope_type', 'scope', name=_('Scope'), html_id='scope'), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -780,7 +780,7 @@ class Meta: class ServiceForm(PrimaryModelForm): parent_object_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS), - widget=HTMXSelect(hx_fieldset_id='service'), + widget=HTMXSelect(hx_target_id='service'), required=True, label=_('Parent type') ) @@ -810,7 +810,7 @@ class ServiceForm(PrimaryModelForm): 'parent_object_type', 'parent', 'name', InlineFields('protocol', 'ports', label=_('Port(s)')), 'ipaddresses', 'description', 'tags', name=_('Application Service'), - fieldset_id='service', + html_id='service', ), ) diff --git a/netbox/project-static/dist/graphiql/js.cookie.min.js b/netbox/project-static/dist/graphiql/js.cookie.min.js index a5d37e23e5a..962d48d0e38 100644 --- a/netbox/project-static/dist/graphiql/js.cookie.min.js +++ b/netbox/project-static/dist/graphiql/js.cookie.min.js @@ -1,2 +1,2 @@ -/*! js-cookie v3.0.8 | MIT */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self,function(){var n=e.Cookies,o=e.Cookies=t();o.noConflict=function(){return e.Cookies=n,o}}())}(this,(function(){"use strict";function e(e){for(var t=1;t +
{% if heading %}

{{ heading }}

diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index 9a0f7a389ce..d382cd698c1 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -176,7 +176,7 @@ def render_fieldset(form, fieldset): return { 'heading': fieldset.name, - 'fieldset_id': fieldset.fieldset_id, + 'html_id': fieldset.html_id, 'rows': rows, } diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index 905e1e76de4..d7f3f0c6896 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -630,23 +630,23 @@ def test_iec_label(self): class FieldSetTestCase(TestCase): - def test_fieldset_id_defaults_to_none(self): + def test_html_id_defaults_to_none(self): fs = FieldSet('field1', 'field2', name='Test') - self.assertIsNone(fs.fieldset_id) + self.assertIsNone(fs.html_id) - def test_fieldset_id_stored(self): - fs = FieldSet('field1', 'field2', name='Test', fieldset_id='my-fieldset') - self.assertEqual(fs.fieldset_id, 'my-fieldset') + def test_html_id_stored(self): + fs = FieldSet('field1', 'field2', name='Test', html_id='my-fieldset') + self.assertEqual(fs.html_id, 'my-fieldset') @override_settings(DEBUG=True) - def test_fieldset_id_invalid_css_identifier_raises(self): + def test_html_id_invalid_css_identifier_raises(self): with self.assertRaises(ValueError): - FieldSet('field1', fieldset_id='123-bad') + FieldSet('field1', html_id='123-bad') @override_settings(DEBUG=False) - def test_fieldset_id_invalid_css_identifier_ignored_outside_debug(self): - fs = FieldSet('field1', fieldset_id='123-bad') - self.assertEqual(fs.fieldset_id, '123-bad') + def test_html_id_invalid_css_identifier_ignored_outside_debug(self): + fs = FieldSet('field1', html_id='123-bad') + self.assertEqual(fs.html_id, '123-bad') class HTMXSelectTestCase(TestCase): @@ -658,12 +658,12 @@ def test_default_targets_form_fields(self): self.assertNotIn('hx-select', widget.attrs) self.assertNotIn('hx-swap', widget.attrs) - def test_hx_fieldset_id_sets_target_select_and_swap(self): - widget = HTMXSelect(hx_fieldset_id='my-fieldset') + def test_hx_target_id_sets_target_select_and_swap(self): + widget = HTMXSelect(hx_target_id='my-fieldset') self.assertEqual(widget.attrs['hx-target'], '#my-fieldset') self.assertEqual(widget.attrs['hx-select'], '#my-fieldset') self.assertEqual(widget.attrs['hx-swap'], 'outerHTML') - def test_hx_fieldset_id_include_stays_on_form_fields(self): - widget = HTMXSelect(hx_fieldset_id='my-fieldset') + def test_hx_target_id_include_stays_on_form_fields(self): + widget = HTMXSelect(hx_target_id='my-fieldset') self.assertEqual(widget.attrs['hx-include'], '#form_fields') diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 3c995cbdb06..5036fa6d85b 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -74,7 +74,7 @@ class ClusterForm(TenancyForm, ScopedForm, PrimaryModelForm): fieldsets = ( FieldSet('name', 'type', 'group', 'status', 'description', 'tags', name=_('Cluster')), - FieldSet('scope_type', 'scope', name=_('Scope'), fieldset_id='scope'), + FieldSet('scope_type', 'scope', name=_('Scope'), html_id='scope'), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -456,7 +456,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm): FieldSet( 'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching'), - fieldset_id='dot1q-switching', + html_id='dot1q-switching', ), ) @@ -471,7 +471,7 @@ class Meta: 'mode': _('802.1Q Mode'), } widgets = { - 'mode': HTMXSelect(hx_fieldset_id='dot1q-switching'), + 'mode': HTMXSelect(hx_target_id='dot1q-switching'), } diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 640d9b733a7..0caf80ea646 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -78,7 +78,7 @@ class TunnelCreateForm(TunnelForm): termination1_type = forms.ChoiceField( choices=TunnelTerminationTypeChoices, required=False, - widget=HTMXSelect(hx_fieldset_id='tunnel-termination1'), + widget=HTMXSelect(hx_target_id='tunnel-termination1'), label=_('Type') ) termination1_parent = DynamicModelChoiceField( @@ -113,7 +113,7 @@ class TunnelCreateForm(TunnelForm): termination2_type = forms.ChoiceField( choices=TunnelTerminationTypeChoices, required=False, - widget=HTMXSelect(hx_fieldset_id='tunnel-termination2'), + widget=HTMXSelect(hx_target_id='tunnel-termination2'), label=_('Type') ) termination2_parent = DynamicModelChoiceField( @@ -145,10 +145,10 @@ class TunnelCreateForm(TunnelForm): FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet( 'termination1_role', 'termination1_type', 'termination1_parent', 'termination1_termination', - 'termination1_outside_ip', name=_('First Termination'), fieldset_id='tunnel-termination1'), + 'termination1_outside_ip', name=_('First Termination'), html_id='tunnel-termination1'), FieldSet( 'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_termination', - 'termination2_outside_ip', name=_('Second Termination'), fieldset_id='tunnel-termination2'), + 'termination2_outside_ip', name=_('Second Termination'), html_id='tunnel-termination2'), ) def __init__(self, *args, initial=None, **kwargs): @@ -225,7 +225,7 @@ class TunnelTerminationForm(NetBoxModelForm): ) type = forms.ChoiceField( choices=TunnelTerminationTypeChoices, - widget=HTMXSelect(hx_fieldset_id='tunnel-termination'), + widget=HTMXSelect(hx_target_id='tunnel-termination'), label=_('Type') ) parent = DynamicModelChoiceField( @@ -252,7 +252,7 @@ class TunnelTerminationForm(NetBoxModelForm): fieldsets = ( FieldSet( 'tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags', - fieldset_id='tunnel-termination', + html_id='tunnel-termination', ), ) diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 5261b40a895..03d1ff68253 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -52,7 +52,7 @@ class WirelessLANForm(ScopedForm, TenancyForm, PrimaryModelForm): fieldsets = ( FieldSet('ssid', 'group', 'vlan', 'status', 'description', 'tags', name=_('Wireless LAN')), - FieldSet('scope_type', 'scope', name=_('Scope'), fieldset_id='scope'), + FieldSet('scope_type', 'scope', name=_('Scope'), html_id='scope'), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), ) From ecdf55f29f7c6eb4595f86244e5d152fef74e735 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Tue, 9 Jun 2026 14:26:22 -0400 Subject: [PATCH 8/9] Restore js.cookie.min.js to feature branch version The file was unintentionally modified by a local pre-commit hook that regenerated graphiql assets. Our changes are Python/template only and do not affect static bundles. --- netbox/project-static/dist/graphiql/js.cookie.min.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/project-static/dist/graphiql/js.cookie.min.js b/netbox/project-static/dist/graphiql/js.cookie.min.js index 962d48d0e38..a5d37e23e5a 100644 --- a/netbox/project-static/dist/graphiql/js.cookie.min.js +++ b/netbox/project-static/dist/graphiql/js.cookie.min.js @@ -1,2 +1,2 @@ -/*! js-cookie v3.0.5 | MIT */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self,function(){var n=e.Cookies,o=e.Cookies=t();o.noConflict=function(){return e.Cookies=n,o}}())}(this,(function(){"use strict";function e(e){for(var t=1;t Date: Thu, 11 Jun 2026 15:08:45 -0400 Subject: [PATCH 9/9] Address Jeremy's review: drop errant HTMXSelect, fix stale comments, add missing html_ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove HTMXSelect from VirtualCircuitTerminationForm.role: the form renders identically regardless of role selection, so the widget was an errant copy-paste - Fix three comments that still read hx_fieldset_id (stale name) → hx_target_id in core, extras, and virtualization forms - Add html_id='scope' to PrefixBulkAddForm's Scope fieldset (was missing, unlike PrefixForm and PrefixBulkEditForm which already had it) - Add html_id='service' to ServiceCreateForm's Application Service fieldset (was missing, unlike the parent ServiceForm which already had it) Co-Authored-By: Claude Sonnet 4.6 --- netbox/circuits/forms/model_forms.py | 1 - netbox/core/forms/model_forms.py | 2 +- netbox/extras/forms/model_forms.py | 2 +- netbox/ipam/forms/model_forms.py | 5 +++-- netbox/virtualization/forms/model_forms.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 3c4b3ba3a22..6f65a949d5a 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -400,7 +400,6 @@ class VirtualCircuitTerminationForm(NetBoxModelForm): ) role = forms.ChoiceField( choices=VirtualCircuitTerminationRoleChoices, - widget=HTMXSelect(hx_target_id='virtual-circuit-termination'), label=_('Role') ) interface = DynamicModelChoiceField( diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py index 8873456b227..5629e778465 100644 --- a/netbox/core/forms/model_forms.py +++ b/netbox/core/forms/model_forms.py @@ -29,7 +29,7 @@ class DataSourceForm(PrimaryModelForm): type = forms.ChoiceField( choices=get_data_backend_choices, - # No hx_fieldset_id: changing type adds/removes the Backend Parameters fieldset entirely. + # No hx_target_id: changing type adds/removes the Backend Parameters fieldset entirely. widget=HTMXSelect() ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 93343aff34a..688f1956324 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -107,7 +107,7 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Mimic HTMXSelect() — no hx_fieldset_id because changing type adds/removes + # Mimic HTMXSelect() — no hx_target_id because changing type adds/removes # Validation, Related Object, and Choices fieldsets dynamically. self.fields['type'].widget.attrs.update({ 'hx-get': '.', diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 7864f7e46dc..ac629c05c84 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -261,7 +261,7 @@ class PrefixBulkAddForm(PrefixForm): FieldSet( 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix') ), - FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('scope_type', 'scope', name=_('Scope'), html_id='scope'), FieldSet('vlan', name=_('VLAN Assignment')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -876,7 +876,8 @@ class ServiceCreateForm(ServiceForm): FieldSet('service_template', name=_('From Template')), FieldSet('name', 'protocol', 'ports', name=_('Custom')), ), - 'ipaddresses', 'description', 'tags', name=_('Application Service') + 'ipaddresses', 'description', 'tags', name=_('Application Service'), + html_id='service', ), ) diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 5036fa6d85b..0fcd06848dc 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -198,7 +198,7 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm): label=_('Type'), queryset=VirtualMachineType.objects.all(), required=False, - # No hx_fieldset_id: type change populates defaults across both the Virtual Machine + # No hx_target_id: type change populates defaults across both the Virtual Machine # and Resources fieldsets, so a single-fieldset partial swap would miss half the update. widget=HTMXSelect(), )