فهرست منبع

Closes: #15165 - HTMX partial fieldset re-rendering for HTMXSelect forms (#22345)

bctiemann 3 هفته پیش
والد
کامیت
1f0d505b91

+ 11 - 6
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_target_id='circuit-termination'),
         label=_('Termination type')
     )
     termination = DynamicModelChoiceField(
@@ -208,7 +208,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 +292,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 +305,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 +399,6 @@ class VirtualCircuitTerminationForm(NetBoxModelForm):
     )
     role = forms.ChoiceField(
         choices=VirtualCircuitTerminationRoleChoices,
-        widget=HTMXSelect(),
         label=_('Role')
     )
     interface = DynamicModelChoiceField(
@@ -412,7 +415,9 @@ class VirtualCircuitTerminationForm(NetBoxModelForm):
     )
 
     fieldsets = (
-        FieldSet('virtual_circuit', 'role', 'interface', 'description', 'tags'),
+        FieldSet(
+            'virtual_circuit', 'role', 'interface', 'description', 'tags',
+        ),
     )
 
     class Meta:

+ 1 - 0
netbox/core/forms/model_forms.py

@@ -29,6 +29,7 @@ EMPTY_VALUES = ('', None, [], ())
 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()
     )
 

+ 17 - 1
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 @@ __all__ = (
 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 @@ class ScopedForm(forms.Form):
         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()
 

+ 7 - 6
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 @@ class ModuleTypeForm(PrimaryModelForm):
         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 InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
             'speed': NumberWithOptions(
                 options=InterfaceSpeedChoices
             ),
-            'mode': HTMXSelect(),
+            'mode': HTMXSelect(hx_target_id='dot1q-switching'),
         }
         labels = {
             'mode': '802.1Q Mode',

+ 4 - 3
netbox/extras/forms/model_forms.py

@@ -107,7 +107,8 @@ class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
     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 EventRuleForm(OwnerMixin, NetBoxModelForm):
         )
         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,
         }

+ 9 - 7
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 ServiceTemplateForm(PrimaryModelForm):
 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',
         ),
     )
 

+ 2 - 2
netbox/templates/dcim/htmx/cable_edit.html

@@ -8,7 +8,7 @@
 {% endfor %}
 
 {# A side termination #}
-<div class="field-group mb-5">
+<div class="field-group mb-5" id="cable-side-a">
   <div class="row">
     <h2 class="col-9 offset-3">{% trans "A Side" %}</h2>
   </div>
@@ -28,7 +28,7 @@
 </div>
 
 {# B side termination #}
-<div class="field-group mb-5">
+<div class="field-group mb-5" id="cable-side-b">
   <div class="row">
     <h2 class="col-9 offset-3">{% trans "B Side" %}</h2>
   </div>

+ 10 - 1
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',
@@ -25,10 +28,16 @@ class FieldSet:
     Parameters:
         items: An iterable of items to be rendered (one per row)
         name: The fieldset's name, displayed as a heading (optional)
+        html_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, html_id=None):
+        if html_id is not None and settings.DEBUG:
+            if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', html_id):
+                raise ValueError(f"html_id {html_id!r} is not a valid CSS identifier")
         self.items = items
         self.name = name
+        self.html_id = html_id
 
 
 class InlineFields:

+ 6 - 3
netbox/utilities/forms/widgets/select.py

@@ -61,15 +61,18 @@ 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_include_id='form_fields', hx_target_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-include': f'#{hx_include_id}',
+            'hx-target': f'#{hx_target_id}' if hx_target_id else f'#{hx_include_id}',
         }
+        if hx_target_id:
+            _attrs['hx-select'] = f'#{hx_target_id}'
+            _attrs['hx-swap'] = 'outerHTML'
         if attrs:
             _attrs.update(attrs)
 

+ 1 - 1
netbox/utilities/templates/form_helpers/render_fieldset.html

@@ -1,6 +1,6 @@
 {% load i18n %}
 {% load form_helpers %}
-<div class="field-group mb-5"{% if heading %} role="group" aria-label="{{ heading }}"{% endif %}>
+<div class="field-group mb-5"{% if html_id %} id="{{ html_id }}"{% endif %}{% if heading %} role="group" aria-label="{{ heading }}"{% endif %}>
   {% if heading %}
     <div class="row">
       <h2 class="col-9 offset-3">{{ heading }}</h2>

+ 1 - 0
netbox/utilities/templatetags/form_helpers.py

@@ -176,6 +176,7 @@ def render_fieldset(form, fieldset):
 
     return {
         'heading': fieldset.name,
+        'html_id': fieldset.html_id,
         'rows': rows,
     }
 

+ 44 - 2
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
@@ -7,13 +7,14 @@ from utilities.forms.bulk_import import BulkImportForm
 from utilities.forms.fields.csv import CSVSelectWidget
 from utilities.forms.fields.dynamic import DynamicChoiceField, DynamicMultipleChoiceField
 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 ExpandIPNetworkTestCase(TestCase):
@@ -625,3 +626,44 @@ class GetCapacityUnitLabelTestCase(TestCase):
 
     def test_iec_label(self):
         self.assertEqual(get_capacity_unit_label(1024), 'MiB')
+
+
+class FieldSetTestCase(TestCase):
+
+    def test_html_id_defaults_to_none(self):
+        fs = FieldSet('field1', 'field2', name='Test')
+        self.assertIsNone(fs.html_id)
+
+    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_html_id_invalid_css_identifier_raises(self):
+        with self.assertRaises(ValueError):
+            FieldSet('field1', html_id='123-bad')
+
+    @override_settings(DEBUG=False)
+    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):
+
+    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_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_target_id_include_stays_on_form_fields(self):
+        widget = HTMXSelect(hx_target_id='my-fieldset')
+        self.assertEqual(widget.attrs['hx-include'], '#form_fields')

+ 6 - 3
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'), html_id='scope'),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
 
@@ -198,6 +198,8 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
         label=_('Type'),
         queryset=VirtualMachineType.objects.all(),
         required=False,
+        # 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(),
     )
     site = DynamicModelChoiceField(
@@ -453,7 +455,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'),
+            html_id='dot1q-switching',
         ),
     )
 
@@ -468,7 +471,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
             'mode': _('802.1Q Mode'),
         }
         widgets = {
-            'mode': HTMXSelect(),
+            'mode': HTMXSelect(hx_target_id='dot1q-switching'),
         }
 
 

+ 9 - 6
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_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(),
+        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')),
+            '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')),
+            '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(),
+        widget=HTMXSelect(hx_target_id='tunnel-termination'),
         label=_('Type')
     )
     parent = DynamicModelChoiceField(
@@ -250,7 +250,10 @@ class TunnelTerminationForm(NetBoxModelForm):
     )
 
     fieldsets = (
-        FieldSet('tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags'),
+        FieldSet(
+            'tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags',
+            html_id='tunnel-termination',
+        ),
     )
 
     class Meta:

+ 1 - 1
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'), html_id='scope'),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
         FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
     )