diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 94e43f0ce7a..6f65a949d5a 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -194,7 +194,8 @@ class CircuitTerminationForm(NetBoxModelForm): ) termination_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), - widget=HTMXSelect(), + widget=HTMXSelect(hx_target_id='circuit-termination'), + required=False, label=_('Termination type') ) termination = DynamicModelChoiceField( @@ -208,7 +209,8 @@ class CircuitTerminationForm(NetBoxModelForm): FieldSet( 'circuit', 'term_side', 'description', 'tags', 'termination_type', 'termination', - 'mark_connected', name=_('Circuit Termination') + 'mark_connected', name=_('Circuit Termination'), + html_id='circuit-termination', ), FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')), ) @@ -291,7 +293,7 @@ class CircuitGroupAssignmentForm(NetBoxModelForm): ) member_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS), - widget=HTMXSelect(), + widget=HTMXSelect(hx_target_id='circuit-group-assignment'), required=False, label=_('Circuit type') ) @@ -304,7 +306,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'), html_id='circuit-group-assignment', + ), ) class Meta: @@ -395,7 +400,6 @@ class VirtualCircuitTerminationForm(NetBoxModelForm): ) role = forms.ChoiceField( choices=VirtualCircuitTerminationRoleChoices, - widget=HTMXSelect(), label=_('Role') ) interface = DynamicModelChoiceField( @@ -412,7 +416,10 @@ class VirtualCircuitTerminationForm(NetBoxModelForm): ) fieldsets = ( - FieldSet('virtual_circuit', 'role', 'interface', 'description', 'tags'), + FieldSet( + 'virtual_circuit', 'role', 'interface', 'description', 'tags', + html_id='virtual-circuit-termination', + ), ) class Meta: diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py index 074ea849833..5629e778465 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_target_id: changing type adds/removes the Backend Parameters fieldset entirely. widget=HTMXSelect() ) diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index 709d6122235..6ddbc01a198 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 @@ -27,7 +30,8 @@ class ScopedForm(forms.Form): scope_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES), - widget=HTMXSelect(), + # 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') ) @@ -50,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, '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 html_id='scope'; HTMX partial swap will fail silently.", + stacklevel=2, + ) + def clean(self): super().clean() diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 7745deb60c2..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() + 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('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(), + widget=HTMXSelect(hx_target_id='cable-side-a'), label=_('Type') ) b_terminations_type = forms.ChoiceField( choices=get_termination_type_choices, required=False, - widget=HTMXSelect(), + widget=HTMXSelect(hx_target_id='cable-side-b'), label=_('Type') ) bundle = DynamicModelChoiceField( @@ -1646,7 +1646,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'), + html_id='dot1q-switching', ), FieldSet( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', @@ -1667,7 +1668,7 @@ class Meta: 'speed': NumberWithOptions( options=InterfaceSpeedChoices ), - 'mode': HTMXSelect(), + '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 f1b23e0d54e..688f1956324 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_target_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', @@ -556,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('action_type', 'action_choice', 'action_data', name=_('Action'), html_id='event-rule-action'), ) class Meta: @@ -567,7 +568,7 @@ class Meta: ) widgets = { 'conditions': forms.Textarea(attrs={'class': 'font-monospace'}), - 'action_type': HTMXSelect(), + '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 bffe7a1eb99..ac629c05c84 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'), html_id='scope'), FieldSet('vlan', name=_('VLAN Assignment')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -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')), ) @@ -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_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('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(), + widget=HTMXSelect(hx_target_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'), + html_id='service', ), ) @@ -875,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/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 #} -