Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
19 changes: 13 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,6 @@ class VirtualCircuitTerminationForm(NetBoxModelForm):
)
role = forms.ChoiceField(
choices=VirtualCircuitTerminationRoleChoices,
widget=HTMXSelect(),
label=_('Role')
)
interface = DynamicModelChoiceField(
Expand All @@ -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:
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_target_id: changing type adds/removes the Backend Parameters fieldset entirely.
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_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',
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
16 changes: 9 additions & 7 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 @@ -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')),
)
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 Expand Up @@ -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',
),
)

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
Loading