Skip to content

Commit 8fce672

Browse files
authored
Merge pull request #21238 from netbox-community/21160-follow-up-null-option
Fixes #21160: Handle "null" choice selection in widgets
2 parents f776b97 + 6d166aa commit 8fce672

File tree

2 files changed

+64
-10
lines changed

2 files changed

+64
-10
lines changed

netbox/utilities/forms/widgets/modifiers.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django import forms
2+
from django.conf import settings
23
from django.utils.translation import gettext_lazy as _
34

45
from utilities.forms.widgets.apiselect import APISelect, APISelectMultiple
@@ -101,21 +102,27 @@ def get_context(self, name, value, attrs):
101102
if isinstance(self.original_widget, (APISelect, APISelectMultiple)):
102103
original_choices = self.original_widget.choices
103104

104-
# Only keep selected choices to preserve current selection in HTML
105+
# Only keep selected choices to preserve the current selection in HTML
105106
if value:
106107
values = value if isinstance(value, (list, tuple)) else [value]
107108

108109
if hasattr(original_choices, 'queryset'):
109-
queryset = original_choices.queryset
110-
selected_objects = queryset.filter(pk__in=values)
111-
# Build minimal choice list with just the selected values
112-
self.original_widget.choices = [
113-
(obj.pk, str(obj)) for obj in selected_objects
114-
]
110+
# Extract valid PKs (exclude special null choice string)
111+
pk_values = [v for v in values if v != settings.FILTERS_NULL_CHOICE_VALUE]
112+
113+
# Build a minimal choice list with just the selected values
114+
choices = []
115+
if pk_values:
116+
selected_objects = original_choices.queryset.filter(pk__in=pk_values)
117+
choices = [(obj.pk, str(obj)) for obj in selected_objects]
118+
119+
# Re-add the "None" option if it was selected via the null choice value
120+
if settings.FILTERS_NULL_CHOICE_VALUE in values:
121+
choices.append((settings.FILTERS_NULL_CHOICE_VALUE, settings.FILTERS_NULL_CHOICE_LABEL))
122+
123+
self.original_widget.choices = choices
115124
else:
116-
self.original_widget.choices = [
117-
choice for choice in original_choices if choice[0] in values
118-
]
125+
self.original_widget.choices = [choice for choice in original_choices if choice[0] in values]
119126
else:
120127
# No selection - render empty select element
121128
self.original_widget.choices = []

netbox/utilities/tests/test_filter_modifiers.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django import forms
2+
from django.conf import settings
23
from django.db import models
34
from django.http import QueryDict
45
from django.template import Context
@@ -14,6 +15,7 @@
1415
from utilities.forms.mixins import FilterModifierMixin
1516
from utilities.forms.widgets import FilterModifierWidget
1617
from utilities.templatetags.helpers import applied_filters
18+
from tenancy.models import Tenant
1719

1820

1921
# Test model for FilterModifierMixin tests
@@ -99,6 +101,51 @@ def test_get_context_includes_original_widget_and_lookups(self):
99101
self.assertEqual(context['widget']['current_modifier'], 'exact') # Defaults to exact, JS updates from URL
100102
self.assertEqual(context['widget']['current_value'], 'test')
101103

104+
def test_get_context_handles_null_selection(self):
105+
"""Widget should preserve the 'null' choice when rendering."""
106+
107+
null_value = settings.FILTERS_NULL_CHOICE_VALUE
108+
null_label = settings.FILTERS_NULL_CHOICE_LABEL
109+
110+
# Simulate a query for objects with no tenant assigned (?tenant_id=null)
111+
query_params = QueryDict(f'tenant_id={null_value}')
112+
form = DeviceFilterForm(query_params)
113+
114+
# Rendering the field triggers FilterModifierWidget.get_context()
115+
try:
116+
html = form['tenant_id'].as_widget()
117+
except ValueError as e:
118+
# ValueError: Field 'id' expected a number but got 'null'
119+
self.fail(f"FilterModifierWidget raised ValueError on 'null' selection: {e}")
120+
121+
# Verify the "None" option is rendered so user selection is preserved in the UI
122+
self.assertIn(f'value="{null_value}"', html)
123+
self.assertIn(null_label, html)
124+
125+
def test_get_context_handles_mixed_selection(self):
126+
"""Widget should preserve both real objects and the 'null' choice together."""
127+
128+
null_value = settings.FILTERS_NULL_CHOICE_VALUE
129+
130+
# Create a tenant to simulate a real object
131+
tenant = Tenant.objects.create(name='Tenant A', slug='tenant-a')
132+
133+
# Simulate a selection containing both a real PK and the null sentinel
134+
query_params = QueryDict('', mutable=True)
135+
query_params.setlist('tenant_id', [str(tenant.pk), null_value])
136+
form = DeviceFilterForm(query_params)
137+
138+
# Rendering the field triggers FilterModifierWidget.get_context()
139+
try:
140+
html = form['tenant_id'].as_widget()
141+
except ValueError as e:
142+
# ValueError: Field 'id' expected a number but got 'null'
143+
self.fail(f"FilterModifierWidget raised ValueError on 'null' selection: {e}")
144+
145+
# Verify both the real object and the null option are present in the output
146+
self.assertIn(f'value="{tenant.pk}"', html)
147+
self.assertIn(f'value="{null_value}"', html)
148+
102149
def test_widget_renders_modifier_dropdown_and_input(self):
103150
"""Widget should render modifier dropdown alongside original input."""
104151
widget = FilterModifierWidget(

0 commit comments

Comments
 (0)