Просмотр исходного кода

IPAM Select2 forms and changelog

John Anderson 7 лет назад
Родитель
Сommit
ad4fb3ce8b

+ 4 - 0
CHANGELOG.md

@@ -1,5 +1,9 @@
 Select2 issues
+* [#2516](https://github.com/digitalocean/netbox/issues/2516) - Implemented Select2 for all Model backed selection fields
 * [#2590](https://github.com/digitalocean/netbox/issues/2590) - Implemented the color picker with Select2 to show colors in the background
+* [#2735](https://github.com/digitalocean/netbox/issues/2735) - Implemented Select2 for all list filter form select elements
+* [#2753](https://github.com/digitalocean/netbox/issues/2753) - Implemented Select2 to replace most all instances of select fields in forms
+
 
 
 v2.5.3 (FUTURE)

+ 0 - 2
netbox/circuits/forms.py

@@ -289,8 +289,6 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
     )
     status = forms.MultipleChoiceField(
         choices=CIRCUIT_STATUS_CHOICES,
-        annotate=Circuit.objects.all(),
-        annotate_field='status',
         required=False,
         widget=StaticSelect2Multiple()
     )

+ 4 - 4
netbox/dcim/forms.py

@@ -1600,7 +1600,7 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         required=False,
         label='Type',
         widget=APISelect(
-            api_url="/api/dcim/device-types"
+            api_url="/api/dcim/device-types/"
         )
     )
     device_role = forms.ModelChoiceField(
@@ -1608,21 +1608,21 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         required=False,
         label='Role',
         widget=APISelect(
-            api_url="/api/dcim/device-roles"
+            api_url="/api/dcim/device-roles/"
         )
     )
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False,
         widget=APISelect(
-            api_url="/api/tenancy/tenants"
+            api_url="/api/tenancy/tenants/"
         )
     )
     platform = forms.ModelChoiceField(
         queryset=Platform.objects.all(),
         required=False,
         widget=APISelect(
-            api_url="/api/dcim/platforms"
+            api_url="/api/dcim/platforms/"
         )
     )
     status = forms.ChoiceField(

+ 256 - 128
netbox/ipam/forms.py

@@ -1,7 +1,6 @@
 from django import forms
 from django.core.exceptions import MultipleObjectsReturned
 from django.core.validators import MaxValueValidator, MinValueValidator
-from django.db.models import Count
 from taggit.forms import TagField
 
 from dcim.models import Site, Rack, Device, Interface
@@ -9,9 +8,9 @@ from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEdit
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
-    AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
-    CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm,
-    SlugField, add_blank_choice,
+    add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
+    CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm, SlugField,
+    StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
 )
 from virtualization.models import VirtualMachine
 from .constants import (
@@ -77,7 +76,10 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm
     )
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
-        required=False
+        required=False,
+        widget=APISelect(
+            api_url="/api/tenancy/tenants/"
+        )
     )
     enforce_unique = forms.NullBooleanField(
         required=False,
@@ -102,11 +104,14 @@ class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
         label='Search'
     )
     tenant = FilterChoiceField(
-        queryset=Tenant.objects.annotate(
-            filter_count=Count('vrfs')
-        ),
+        queryset=Tenant.objects.all(),
         to_field_name='slug',
-        null_label='-- None --'
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenants/",
+            value_field="slug",
+            null_option=True,
+        )
     )
 
 
@@ -139,12 +144,8 @@ class RIRFilterForm(BootstrapMixin, forms.Form):
     is_private = forms.NullBooleanField(
         required=False,
         label='Private',
-        widget=forms.Select(
-            choices=[
-                ('', '---------'),
-                ('True', 'Yes'),
-                ('False', 'No'),
-            ]
+        widget=StaticSelect2(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
         )
     )
 
@@ -168,6 +169,11 @@ class AggregateForm(BootstrapMixin, CustomFieldForm):
             'rir': "Regional Internet Registry responsible for this prefix",
             'date_added': "Format: YYYY-MM-DD",
         }
+        widgets = {
+            'rir': APISelect(
+                api_url="/api/ipam/rirs/"
+            )
+        }
 
 
 class AggregateCSVForm(forms.ModelForm):
@@ -193,7 +199,10 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
     rir = forms.ModelChoiceField(
         queryset=RIR.objects.all(),
         required=False,
-        label='RIR'
+        label='RIR',
+        widget=APISelect(
+            api_url="/api/ipam/rirs/"
+        )
     )
     date_added = forms.DateField(
         required=False
@@ -218,12 +227,17 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
     family = forms.ChoiceField(
         required=False,
         choices=IP_FAMILY_CHOICES,
-        label='Address family'
+        label='Address family',
+        widget=StaticSelect2()
     )
     rir = FilterChoiceField(
-        queryset=RIR.objects.annotate(filter_count=Count('aggregates')),
+        queryset=RIR.objects.all(),
         to_field_name='slug',
-        label='RIR'
+        label='RIR',
+        widget=APISelectMultiple(
+            api_url="/api/ipam/rirs/",
+            value_field="slug",
+        )
     )
 
 
@@ -261,9 +275,13 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         queryset=Site.objects.all(),
         required=False,
         label='Site',
-        widget=forms.Select(
+        widget=APISelect(
+            api_url="/api/dcim/sites/",
+            filter_for={
+                'vlan_group': 'site_id',
+                'vlan': 'site_id',
+            },
             attrs={
-                'filter-for': 'vlan_group',
                 'nullable': 'true',
             }
         )
@@ -276,9 +294,11 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         required=False,
         label='VLAN group',
         widget=APISelect(
-            api_url='/api/ipam/vlan-groups/?site_id={{site}}',
+            api_url='/api/ipam/vlan-groups/',
+            filter_for={
+                'vlan': 'group_id'
+            },
             attrs={
-                'filter-for': 'vlan',
                 'nullable': 'true',
             }
         )
@@ -292,7 +312,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         required=False,
         label='VLAN',
         widget=APISelect(
-            api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
+            api_url='/api/ipam/vlans/',
             display_field='display_name'
         )
     )
@@ -304,6 +324,15 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant',
             'tags',
         ]
+        widgets = {
+            'vrf': APISelect(
+                api_url="/api/ipam/vrfs/"
+            ),
+            'status': StaticSelect2(),
+            'role': APISelect(
+                api_url="/api/ipam/roles/"
+            )
+        }
 
     def __init__(self, *args, **kwargs):
 
@@ -415,12 +444,18 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
     )
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        widget=APISelect(
+            api_url="/api/dcim/sites/"
+        )
     )
     vrf = forms.ModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
-        label='VRF'
+        label='VRF',
+        widget=APISelect(
+            api_url="/api/ipam/vrfs/"
+        )
     )
     prefix_length = forms.IntegerField(
         min_value=1,
@@ -429,15 +464,22 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
     )
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
-        required=False
+        required=False,
+        widget=APISelect(
+            api_url="/api/tenancy/tenants/"
+        )
     )
     status = forms.ChoiceField(
         choices=add_blank_choice(PREFIX_STATUS_CHOICES),
-        required=False
+        required=False,
+        widget=StaticSelect2()
     )
     role = forms.ModelChoiceField(
         queryset=Role.objects.all(),
-        required=False
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/roles/"
+        )
     )
     is_pool = forms.NullBooleanField(
         required=False,
@@ -473,47 +515,60 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
     family = forms.ChoiceField(
         required=False,
         choices=IP_FAMILY_CHOICES,
-        label='Address family'
+        label='Address family',
+        widget=StaticSelect2()
     )
     mask_length = forms.ChoiceField(
         required=False,
         choices=PREFIX_MASK_LENGTH_CHOICES,
-        label='Mask length'
+        label='Mask length',
+        widget=StaticSelect2()
     )
     vrf = FilterChoiceField(
-        queryset=VRF.objects.annotate(
-            filter_count=Count('prefixes')
-        ),
+        queryset=VRF.objects.all(),
         to_field_name='rd',
         label='VRF',
-        null_label='-- Global --'
+        null_label='-- Global --',
+        widget=APISelectMultiple(
+            api_url="/api/ipam/vrfs/",
+            value_field="slug",
+            null_option=True,
+        )
     )
     tenant = FilterChoiceField(
-        queryset=Tenant.objects.annotate(
-            filter_count=Count('prefixes')
-        ),
+        queryset=Tenant.objects.all(),
         to_field_name='slug',
-        null_label='-- None --'
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenants/",
+            value_field="slug",
+            null_option=True,
+        )
     )
-    status = AnnotatedMultipleChoiceField(
+    status = forms.MultipleChoiceField(
         choices=PREFIX_STATUS_CHOICES,
-        annotate=Prefix.objects.all(),
-        annotate_field='status',
-        required=False
+        required=False,
+        widget=StaticSelect2Multiple()
     )
     site = FilterChoiceField(
-        queryset=Site.objects.annotate(
-            filter_count=Count('prefixes')
-        ),
+        queryset=Site.objects.all(),
         to_field_name='slug',
-        null_label='-- None --'
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/",
+            value_field="slug",
+            null_option=True,
+        )
     )
     role = FilterChoiceField(
-        queryset=Role.objects.annotate(
-            filter_count=Count('prefixes')
-        ),
+        queryset=Role.objects.all(),
         to_field_name='slug',
-        null_label='-- None --'
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/ipam/roles/",
+            value_field="slug",
+            null_option=True,
+        )
     )
     expand = forms.BooleanField(
         required=False,
@@ -534,9 +589,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
         queryset=Site.objects.all(),
         required=False,
         label='Site',
-        widget=forms.Select(
-            attrs={
-                'filter-for': 'nat_rack'
+        widget=APISelect(
+            api_url="/api/dcim/sites/",
+            filter_for={
+                'nat_rack': 'site_id',
+                'nat_device': 'site_id'
             }
         )
     )
@@ -548,10 +605,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
         required=False,
         label='Rack',
         widget=APISelect(
-            api_url='/api/dcim/racks/?site_id={{nat_site}}',
+            api_url='/api/dcim/racks/',
             display_field='display_name',
+            filter_for={
+                'nat_device': 'rack_id'
+            },
             attrs={
-                'filter-for': 'nat_device',
                 'nullable': 'true'
             }
         )
@@ -565,9 +624,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
         required=False,
         label='Device',
         widget=APISelect(
-            api_url='/api/dcim/devices/?site_id={{nat_site}}&rack_id={{nat_rack}}',
+            api_url='/api/dcim/devices/',
             display_field='display_name',
-            attrs={'filter-for': 'nat_inside'}
+            filter_for={
+                'nat_inside': 'device_id'
+            }
         )
     )
     nat_inside = ChainedModelChoiceField(
@@ -578,20 +639,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
         required=False,
         label='IP Address',
         widget=APISelect(
-            api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}',
+            api_url='/api/ipam/ip-addresses/',
             display_field='address'
         )
     )
-    livesearch = forms.CharField(
-        required=False,
-        label='Search',
-        widget=Livesearch(
-            query_key='q',
-            query_url='ipam-api:ipaddress-list',
-            field_to_update='nat_inside',
-            obj_label='address'
-        )
-    )
     primary_for_parent = forms.BooleanField(
         required=False,
         label='Make this the primary IP for the device/VM'
@@ -606,6 +657,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
             'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site',
             'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags',
         ]
+        widgets = {
+            'status': StaticSelect2(),
+            'role': StaticSelect2(),
+            'vrf': APISelect(
+                api_url="/api/ipam/vrfs/"
+            )
+        }
 
     def __init__(self, *args, **kwargs):
 
@@ -685,6 +743,13 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         fields = [
             'address', 'vrf', 'status', 'role', 'description', 'tenant_group', 'tenant',
         ]
+        widgets = {
+            'status': StaticSelect2(),
+            'role': StaticSelect2(),
+            'vrf': APISelect(
+                api_url="/api/ipam/vrfs/"
+            )
+        }
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -822,7 +887,10 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
     vrf = forms.ModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
-        label='VRF'
+        label='VRF',
+        widget=APISelect(
+            api_url="/api/ipam/vrfs/"
+        )
     )
     mask_length = forms.IntegerField(
         min_value=1,
@@ -831,15 +899,20 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
     )
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
-        required=False
+        required=False,
+        widget=APISelect(
+            api_url="/api/tenancy/tenants/"
+        )
     )
     status = forms.ChoiceField(
         choices=add_blank_choice(IPADDRESS_STATUS_CHOICES),
-        required=False
+        required=False,
+        widget=StaticSelect2()
     )
     role = forms.ChoiceField(
         choices=add_blank_choice(IPADDRESS_ROLE_CHOICES),
-        required=False
+        required=False,
+        widget=StaticSelect2()
     )
     description = forms.CharField(
         max_length=100, required=False
@@ -856,7 +929,10 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
         queryset=VRF.objects.all(),
         required=False,
         label='VRF',
-        empty_label='Global'
+        empty_label='Global',
+        widget=APISelect(
+            api_url="/api/ipam/vrfs/"
+        )
     )
     address = forms.CharField(
         label='IP Address'
@@ -881,39 +957,45 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
     family = forms.ChoiceField(
         required=False,
         choices=IP_FAMILY_CHOICES,
-        label='Address family'
+        label='Address family',
+        widget=StaticSelect2()
     )
     mask_length = forms.ChoiceField(
         required=False,
         choices=IPADDRESS_MASK_LENGTH_CHOICES,
-        label='Mask length'
+        label='Mask length',
+        widget=StaticSelect2()
     )
     vrf = FilterChoiceField(
-        queryset=VRF.objects.annotate(
-            filter_count=Count('ip_addresses')
-        ),
+        queryset=VRF.objects.all(),
         to_field_name='rd',
         label='VRF',
-        null_label='-- Global --'
+        null_label='-- Global --',
+        widget=APISelectMultiple(
+            api_url="/api/ipam/vrfs/",
+            value_field="slug",
+            null_option=True,
+        )
     )
     tenant = FilterChoiceField(
-        queryset=Tenant.objects.annotate(
-            filter_count=Count('ip_addresses')
-        ),
+        queryset=Tenant.objects.all(),
         to_field_name='slug',
-        null_label='-- None --'
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenants/",
+            value_field="slug",
+            null_option=True,
+        )
     )
-    status = AnnotatedMultipleChoiceField(
+    status = forms.MultipleChoiceField(
         choices=IPADDRESS_STATUS_CHOICES,
-        annotate=IPAddress.objects.all(),
-        annotate_field='status',
-        required=False
+        required=False,
+        widget=StaticSelect2Multiple()
     )
-    role = AnnotatedMultipleChoiceField(
+    role = forms.MultipleChoiceField(
         choices=IPADDRESS_ROLE_CHOICES,
-        annotate=IPAddress.objects.all(),
-        annotate_field='role',
-        required=False
+        required=False,
+        widget=StaticSelect2Multiple()
     )
 
 
@@ -929,6 +1011,11 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
         fields = [
             'site', 'name', 'slug',
         ]
+        widgets = {
+            'site': APISelect(
+                api_url="/api/dcim/sites/"
+            )
+        }
 
 
 class VLANGroupCSVForm(forms.ModelForm):
@@ -953,11 +1040,14 @@ class VLANGroupCSVForm(forms.ModelForm):
 
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
     site = FilterChoiceField(
-        queryset=Site.objects.annotate(
-            filter_count=Count('vlan_groups')
-        ),
+        queryset=Site.objects.all(),
         to_field_name='slug',
-        null_label='-- Global --'
+        null_label='-- Global --',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/",
+            value_field="slug",
+            null_option=True,
+        )
     )
 
 
@@ -969,9 +1059,12 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         required=False,
-        widget=forms.Select(
+        widget=APISelect(
+            api_url="/api/dcim/sites/",
+            filter_for={
+                'group': 'site_id'
+            },
             attrs={
-                'filter-for': 'group',
                 'nullable': 'true',
             }
         )
@@ -984,7 +1077,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         required=False,
         label='Group',
         widget=APISelect(
-            api_url='/api/ipam/vlan-groups/?site_id={{site}}',
+            api_url='/api/ipam/vlan-groups/',
         )
     )
     tags = TagField(required=False)
@@ -1002,6 +1095,12 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             'status': "Operational status of this VLAN",
             'role': "The primary function of this VLAN",
         }
+        widgets = {
+            'status': StaticSelect2(),
+            'role': APISelect(
+                api_url="/api/ipam/roles/"
+            )
+        }
 
 
 class VLANCSVForm(forms.ModelForm):
@@ -1077,23 +1176,36 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
     )
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
-        required=False
+        required=False,
+        widget=APISelect(
+            api_url="/api/dcim/sites/"
+        )
     )
     group = forms.ModelChoiceField(
         queryset=VLANGroup.objects.all(),
-        required=False
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/vlan-groups/"
+        )
     )
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
-        required=False
+        required=False,
+        widget=APISelect(
+            api_url="/api/tenancy/tenants/"
+        )
     )
     status = forms.ChoiceField(
         choices=add_blank_choice(VLAN_STATUS_CHOICES),
-        required=False
+        required=False,
+        widget=StaticSelect2()
     )
     role = forms.ModelChoiceField(
         queryset=Role.objects.all(),
-        required=False
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/roles/"
+        )
     )
     description = forms.CharField(
         max_length=100,
@@ -1113,38 +1225,48 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
         label='Search'
     )
     site = FilterChoiceField(
-        queryset=Site.objects.annotate(
-            filter_count=Count('vlans')
-        ),
+        queryset=Site.objects.all(),
         to_field_name='slug',
-        null_label='-- Global --'
+        null_label='-- Global --',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/",
+            value_field="slug",
+            null_option=True,
+        )
     )
     group_id = FilterChoiceField(
-        queryset=VLANGroup.objects.annotate(
-            filter_count=Count('vlans')
-        ),
+        queryset=VLANGroup.objects.all(),
         label='VLAN group',
-        null_label='-- None --'
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/ipam/vlan-groups/",
+            null_option=True,
+        )
     )
     tenant = FilterChoiceField(
-        queryset=Tenant.objects.annotate(
-            filter_count=Count('vlans')
-        ),
+        queryset=Tenant.objects.all(),
         to_field_name='slug',
-        null_label='-- None --'
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenants/",
+            value_field="slug",
+            null_option=True,
+        )
     )
-    status = AnnotatedMultipleChoiceField(
+    status = forms.MultipleChoiceField(
         choices=VLAN_STATUS_CHOICES,
-        annotate=VLAN.objects.all(),
-        annotate_field='status',
-        required=False
+        required=False,
+        widget=StaticSelect2Multiple()
     )
     role = FilterChoiceField(
-        queryset=Role.objects.annotate(
-            filter_count=Count('vlans')
-        ),
+        queryset=Role.objects.all(),
         to_field_name='slug',
-        null_label='-- None --'
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/ipam/roles/",
+            value_field="slug",
+            null_option=True,
+        )
     )
 
 
@@ -1166,6 +1288,10 @@ class ServiceForm(BootstrapMixin, CustomFieldForm):
             'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be "
                            "reachable via all IPs assigned to the device.",
         }
+        widgets = {
+            'protocol': StaticSelect2(),
+            'ipaddresses': StaticSelect2Multiple(),
+        }
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -1192,10 +1318,11 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     )
     protocol = forms.ChoiceField(
         choices=add_blank_choice(IP_PROTOCOL_CHOICES),
-        required=False
+        required=False,
+        widget=StaticSelect2Multiple()
     )
     port = forms.IntegerField(
-        required=False
+        required=False,
     )
 
 
@@ -1206,7 +1333,8 @@ class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     )
     protocol = forms.ChoiceField(
         choices=add_blank_choice(IP_PROTOCOL_CHOICES),
-        required=False
+        required=False,
+        widget=StaticSelect2()
     )
     port = forms.IntegerField(
         validators=[
@@ -1222,5 +1350,5 @@ class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
     class Meta:
         nullable_fields = [
-            'site', 'group', 'tenant', 'role', 'description',
+            'site', 'tenant', 'role', 'description',
         ]

+ 1 - 1
netbox/templates/ipam/ipaddress_edit.html

@@ -60,7 +60,7 @@
                     {% render_field form.nat_device %}
                 </div>
                 <div class="tab-pane" id="search">
-                    {% render_field form.livesearch %}
+                    &nbsp;
                 </div>
             </div>
             {% render_field form.nat_inside %}

+ 3 - 5
netbox/utilities/forms.py

@@ -717,17 +717,15 @@ class ChainedFieldsMixin(forms.BaseForm):
 
                 filters_dict = {}
                 for (db_field, parent_field) in field.chains:
-                    if self.fields[parent_field].widget.attrs.get('nullable'):
-                        filters_dict[db_field] = None
-                    elif self.is_bound and parent_field in self.data and self.data[parent_field]:
+                    if self.is_bound and parent_field in self.data and self.data[parent_field]:
                         filters_dict[db_field] = self.data[parent_field] or None
                     elif self.initial.get(parent_field):
                         filters_dict[db_field] = self.initial[parent_field]
+                    elif self.fields[parent_field].widget.attrs.get('nullable'):
+                        filters_dict[db_field] = None
                     else:
                         break
 
-                print(filters_dict)
-
                 if filters_dict:
                     field.queryset = field.queryset.filter(**filters_dict)
                 elif not self.is_bound and getattr(self, 'instance', None) and hasattr(self.instance, field_name):