diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 4e405a035..f5e3bca30 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -1,9 +1,9 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils.translation import gettext_lazy as _ -from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup +from dcim.models import Device, Interface, Site from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField @@ -17,8 +17,10 @@ from utilities.forms.fields import ( SlugField, ) from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups -from utilities.forms.widgets import DatePicker -from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface +from utilities.forms.utils import get_field_value +from utilities.forms.widgets import DatePicker, HTMXSelect +from utilities.templatetags.builtins.filters import bettertitle +from virtualization.models import VirtualMachine, VMInterface __all__ = ( 'AggregateForm', @@ -562,91 +564,31 @@ class FHRPGroupAssignmentForm(forms.ModelForm): class VLANGroupForm(NetBoxModelForm): - scope_type = ContentTypeChoiceField( - label=_('Scope type'), - queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - required=False - ) - region = DynamicModelChoiceField( - label=_('Region'), - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - sitegroup = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - }, - label=_('Site group') - ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False, - initial_params={ - 'locations': '$location' - }, - query_params={ - 'region_id': '$region', - 'group_id': '$sitegroup', - } - ) - location = DynamicModelChoiceField( - label=_('Location'), - queryset=Location.objects.all(), - required=False, - initial_params={ - 'racks': '$rack' - }, - query_params={ - 'site_id': '$site', - } - ) - rack = DynamicModelChoiceField( - label=_('Rack'), - queryset=Rack.objects.all(), - required=False, - query_params={ - 'site_id': '$site', - 'location_id': '$location', - } - ) - clustergroup = DynamicModelChoiceField( - queryset=ClusterGroup.objects.all(), - required=False, - initial_params={ - 'clusters': '$cluster' - }, - label=_('Cluster group') - ) - cluster = DynamicModelChoiceField( - label=_('Cluster'), - queryset=Cluster.objects.all(), - required=False, - query_params={ - 'group_id': '$clustergroup', - } - ) slug = SlugField() + scope_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), + widget=HTMXSelect(), + required=False, + label=_('Scope type') + ) + scope = DynamicModelChoiceField( + label=_('Scope'), + queryset=Site.objects.none(), # Initial queryset + required=False, + disabled=True, + selector=True + ) fieldsets = ( FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')), FieldSet('min_vid', 'max_vid', name=_('Child VLANs')), - FieldSet( - 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', - name=_('Scope') - ), + FieldSet('scope_type', 'scope', name=_('Scope')), ) class Meta: model = VLANGroup fields = [ - 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', - 'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags', + 'name', 'slug', 'description', 'min_vid', 'max_vid', 'scope_type', 'scope', 'tags', ] def __init__(self, *args, **kwargs): @@ -654,21 +596,30 @@ class VLANGroupForm(NetBoxModelForm): initial = kwargs.get('initial', {}) if instance is not None and instance.scope: - initial[instance.scope_type.model] = instance.scope - + initial['scope'] = instance.scope kwargs['initial'] = initial super().__init__(*args, **kwargs) + if scope_type_id := get_field_value(self, 'scope_type'): + try: + scope_type = ContentType.objects.get(pk=scope_type_id) + model = scope_type.model_class() + self.fields['scope'].queryset = model.objects.all() + self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower + self.fields['scope'].disabled = False + self.fields['scope'].label = _(bettertitle(model._meta.verbose_name)) + except ObjectDoesNotExist: + pass + + if self.instance and scope_type_id != self.instance.scope_type_id: + self.initial['scope'] = None + def clean(self): super().clean() - # Assign scope based on scope_type - if self.cleaned_data.get('scope_type'): - scope_field = self.cleaned_data['scope_type'].model - self.instance.scope = self.cleaned_data.get(scope_field) - else: - self.instance.scope_id = None + # Assign the selected scope (if any) + self.instance.scope = self.cleaned_data.get('scope') class VLANForm(TenancyForm, NetBoxModelForm): diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index d86103d2a..afdbea0f8 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 76cd496f3..212be3659 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/forms/index.ts b/netbox/project-static/src/forms/index.ts index f166c75a8..00b872e27 100644 --- a/netbox/project-static/src/forms/index.ts +++ b/netbox/project-static/src/forms/index.ts @@ -1,9 +1,8 @@ import { initFormElements } from './elements'; import { initSpeedSelector } from './speedSelector'; -import { initScopeSelector } from './scopeSelector'; export function initForms(): void { - for (const func of [initFormElements, initSpeedSelector, initScopeSelector]) { + for (const func of [initFormElements, initSpeedSelector]) { func(); } } diff --git a/netbox/project-static/src/forms/scopeSelector.ts b/netbox/project-static/src/forms/scopeSelector.ts deleted file mode 100644 index f7b77f041..000000000 --- a/netbox/project-static/src/forms/scopeSelector.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { getElements, toggleVisibility } from '../util'; - -type ShowHideMap = { - /** - * Name of view to which this map should apply. - * - * @example vlangroup_edit - */ - [view: string]: string; -}; - -type ShowHideLayout = { - /** - * Name of layout config - * - * @example vlangroup - */ - [config: string]: { - /** - * Default layout. - */ - default: { hide: string[]; show: string[] }; - /** - * Field name to layout mapping. - */ - [fieldName: string]: { hide: string[]; show: string[] }; - }; -}; - -/** - * Mapping of layout names to arrays of object types whose fields should be hidden or shown when - * the scope type (key) is selected. - * - * For example, if `region` is the scope type, the fields with IDs listed in - * showHideMap.region.hide should be hidden, and the fields with IDs listed in - * showHideMap.region.show should be shown. - */ -const showHideLayout: ShowHideLayout = { - vlangroup: { - region: { - hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'], - show: ['id_region'], - }, - 'site group': { - hide: ['id_region', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'], - show: ['id_sitegroup'], - }, - site: { - hide: ['id_location', 'id_rack', 'id_clustergroup', 'id_cluster'], - show: ['id_region', 'id_sitegroup', 'id_site'], - }, - location: { - hide: ['id_rack', 'id_clustergroup', 'id_cluster'], - show: ['id_region', 'id_sitegroup', 'id_site', 'id_location'], - }, - rack: { - hide: ['id_clustergroup', 'id_cluster'], - show: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'], - }, - 'cluster group': { - hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_cluster'], - show: ['id_clustergroup'], - }, - cluster: { - hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'], - show: ['id_clustergroup', 'id_cluster'], - }, - default: { - hide: [ - 'id_region', - 'id_sitegroup', - 'id_site', - 'id_location', - 'id_rack', - 'id_clustergroup', - 'id_cluster', - ], - show: [], - }, - }, -}; - -/** - * Mapping of view names to layout configurations - * - * For example, if `vlangroup_add` is the view, use the layout configuration `vlangroup`. - */ -const showHideMap: ShowHideMap = { - vlangroup_add: 'vlangroup', - vlangroup_edit: 'vlangroup', - vlangroup_bulk_edit: 'vlangroup', -}; - -/** - * Toggle visibility of a given element's parent. - * @param query CSS Query. - * @param action Show or Hide the Parent. - */ -function toggleParentVisibility(query: string, action: 'show' | 'hide') { - for (const element of getElements(query)) { - const parent = element.parentElement?.parentElement as Nullable; - if (parent !== null) { - if (action === 'show') { - toggleVisibility(parent, 'show'); - } else { - toggleVisibility(parent, 'hide'); - } - } - } -} - -/** - * Handle changes to the Scope Type field. - */ -function handleScopeChange

(view: P, element: HTMLSelectElement) { - // Scope type's innerText looks something like `DCIM > region`. - const scopeType = element.options[element.selectedIndex].innerText.toLowerCase(); - const layoutConfig = showHideMap[view]; - - for (const [scope, fields] of Object.entries(showHideLayout[layoutConfig])) { - // If the scope type ends with the specified scope, toggle its field visibility according to - // the show/hide values. - if (scopeType.endsWith(scope)) { - for (const field of fields.hide) { - toggleParentVisibility(`#${field}`, 'hide'); - } - for (const field of fields.show) { - toggleParentVisibility(`#${field}`, 'show'); - } - // Stop on first match. - break; - } else { - // Otherwise, hide all fields. - for (const field of showHideLayout[layoutConfig].default.hide) { - toggleParentVisibility(`#${field}`, 'hide'); - } - } - } -} - -/** - * Initialize scope type select event listeners. - */ -export function initScopeSelector(): void { - for (const view of Object.keys(showHideMap)) { - for (const element of getElements( - `html[data-netbox-url-name="${view}"] #id_scope_type`, - )) { - handleScopeChange(view, element); - element.addEventListener('change', () => handleScopeChange(view, element)); - } - } -}