Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions netbox/circuits/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
bctiemann marked this conversation as resolved.
label=_('Termination type')
)
termination = DynamicModelChoiceField(
Expand All @@ -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')),
)
Expand Down Expand Up @@ -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')
)
Expand All @@ -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:
Expand Down Expand Up @@ -395,7 +400,7 @@ class VirtualCircuitTerminationForm(NetBoxModelForm):
)
role = forms.ChoiceField(
choices=VirtualCircuitTerminationRoleChoices,
widget=HTMXSelect(),
widget=HTMXSelect(hx_target_id='virtual-circuit-termination'),
Comment thread
bctiemann marked this conversation as resolved.
Outdated
label=_('Role')
)
interface = DynamicModelChoiceField(
Expand All @@ -412,7 +417,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:
Expand Down
1 change: 1 addition & 0 deletions netbox/core/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
bctiemann marked this conversation as resolved.
Outdated
widget=HTMXSelect()
)

Expand Down
18 changes: 17 additions & 1 deletion netbox/dcim/forms/mixins.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
)
Expand All @@ -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()

Expand Down
13 changes: 7 additions & 6 deletions netbox/dcim/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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',
Expand All @@ -1667,7 +1668,7 @@ class Meta:
'speed': NumberWithOptions(
options=InterfaceSpeedChoices
),
'mode': HTMXSelect(),
'mode': HTMXSelect(hx_target_id='dot1q-switching'),
}
labels = {
'mode': '802.1Q Mode',
Expand Down
7 changes: 4 additions & 3 deletions netbox/extras/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
bctiemann marked this conversation as resolved.
Outdated
# Validation, Related Object, and Choices fieldsets dynamically.
self.fields['type'].widget.attrs.update({
'hx-get': '.',
'hx-include': '#form_fields',
Expand Down Expand Up @@ -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:
Expand All @@ -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,
}
Expand Down
11 changes: 6 additions & 5 deletions netbox/ipam/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Comment thread
bctiemann marked this conversation as resolved.
FieldSet('vlan', name=_('VLAN Assignment')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
Expand Down Expand Up @@ -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')
)
Expand All @@ -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')),
)

Expand Down Expand Up @@ -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')
)
Expand Down Expand Up @@ -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',
Comment thread
bctiemann marked this conversation as resolved.
),
)

Expand Down
4 changes: 2 additions & 2 deletions netbox/templates/dcim/htmx/cable_edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -28,7 +28,7 @@ <h2 class="col-9 offset-3">{% trans "A Side" %}</h2>
</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>
Expand Down
11 changes: 10 additions & 1 deletion netbox/utilities/forms/rendering.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import random
import re
import string
from functools import cached_property

from django.conf import settings

__all__ = (
'FieldSet',
'InlineFields',
Expand All @@ -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:
Comment thread
bctiemann marked this conversation as resolved.
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:
Expand Down
9 changes: 6 additions & 3 deletions netbox/utilities/forms/widgets/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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>
Expand Down
1 change: 1 addition & 0 deletions netbox/utilities/templatetags/form_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ def render_fieldset(form, fieldset):

return {
'heading': fieldset.name,
'html_id': fieldset.html_id,
'rows': rows,
}

Expand Down
46 changes: 44 additions & 2 deletions netbox/utilities/tests/test_forms.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
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
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):
Expand Down Expand Up @@ -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')
Loading