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 #} -
+

{% trans "A Side" %}

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

{% trans "A Side" %}

{# B side termination #} -
+

{% trans "B Side" %}

diff --git a/netbox/utilities/forms/rendering.py b/netbox/utilities/forms/rendering.py index c7bb324d899..66014c3a1a8 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', @@ -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: diff --git a/netbox/utilities/forms/widgets/select.py b/netbox/utilities/forms/widgets/select.py index 7297f483f3e..5112d34fb3f 100644 --- a/netbox/utilities/forms/widgets/select.py +++ b/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) diff --git a/netbox/utilities/templates/form_helpers/render_fieldset.html b/netbox/utilities/templates/form_helpers/render_fieldset.html index 82a17540b51..1ae1b8097ef 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 5b5ff0fe82d..d382cd698c1 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/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, } diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index 79101220002..d7f3f0c6896 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 @@ -7,13 +7,14 @@ 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 @@ def test_si_label(self): 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') diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 3be14d39695..0fcd06848dc 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'), 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 Meta: 'mode': _('802.1Q Mode'), } widgets = { - 'mode': HTMXSelect(), + 'mode': HTMXSelect(hx_target_id='dot1q-switching'), } diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 82bc79e4e81..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(), + 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: diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 0cd107ba6e4..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('scope_type', 'scope', name=_('Scope'), html_id='scope'), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), )