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

This commit is contained in:
bctiemann 2026-06-13 17:57:40 -04:00 committed by GitHub
parent ac513345b5
commit 1f0d505b91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 129 additions and 42 deletions

View File

@ -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:

View File

@ -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()
)

View File

@ -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()

View File

@ -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',

View File

@ -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,
}

View File

@ -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',
),
)

View File

@ -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>

View File

@ -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:

View File

@ -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)

View File

@ -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>

View File

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

View File

@ -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')

View File

@ -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'),
}

View File

@ -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:

View File

@ -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')),
)