mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-20 18:48:45 -06:00
Merge pull request #21238 from netbox-community/21160-follow-up-null-option
Fixes #21160: Handle "null" choice selection in widgets
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from utilities.forms.widgets.apiselect import APISelect, APISelectMultiple
|
from utilities.forms.widgets.apiselect import APISelect, APISelectMultiple
|
||||||
@@ -101,21 +102,27 @@ class FilterModifierWidget(forms.Widget):
|
|||||||
if isinstance(self.original_widget, (APISelect, APISelectMultiple)):
|
if isinstance(self.original_widget, (APISelect, APISelectMultiple)):
|
||||||
original_choices = self.original_widget.choices
|
original_choices = self.original_widget.choices
|
||||||
|
|
||||||
# Only keep selected choices to preserve current selection in HTML
|
# Only keep selected choices to preserve the current selection in HTML
|
||||||
if value:
|
if value:
|
||||||
values = value if isinstance(value, (list, tuple)) else [value]
|
values = value if isinstance(value, (list, tuple)) else [value]
|
||||||
|
|
||||||
if hasattr(original_choices, 'queryset'):
|
if hasattr(original_choices, 'queryset'):
|
||||||
queryset = original_choices.queryset
|
# Extract valid PKs (exclude special null choice string)
|
||||||
selected_objects = queryset.filter(pk__in=values)
|
pk_values = [v for v in values if v != settings.FILTERS_NULL_CHOICE_VALUE]
|
||||||
# Build minimal choice list with just the selected values
|
|
||||||
self.original_widget.choices = [
|
# Build a minimal choice list with just the selected values
|
||||||
(obj.pk, str(obj)) for obj in selected_objects
|
choices = []
|
||||||
]
|
if pk_values:
|
||||||
|
selected_objects = original_choices.queryset.filter(pk__in=pk_values)
|
||||||
|
choices = [(obj.pk, str(obj)) for obj in selected_objects]
|
||||||
|
|
||||||
|
# Re-add the "None" option if it was selected via the null choice value
|
||||||
|
if settings.FILTERS_NULL_CHOICE_VALUE in values:
|
||||||
|
choices.append((settings.FILTERS_NULL_CHOICE_VALUE, settings.FILTERS_NULL_CHOICE_LABEL))
|
||||||
|
|
||||||
|
self.original_widget.choices = choices
|
||||||
else:
|
else:
|
||||||
self.original_widget.choices = [
|
self.original_widget.choices = [choice for choice in original_choices if choice[0] in values]
|
||||||
choice for choice in original_choices if choice[0] in values
|
|
||||||
]
|
|
||||||
else:
|
else:
|
||||||
# No selection - render empty select element
|
# No selection - render empty select element
|
||||||
self.original_widget.choices = []
|
self.original_widget.choices = []
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http import QueryDict
|
from django.http import QueryDict
|
||||||
from django.template import Context
|
from django.template import Context
|
||||||
@@ -14,6 +15,7 @@ from utilities.forms.fields import TagFilterField
|
|||||||
from utilities.forms.mixins import FilterModifierMixin
|
from utilities.forms.mixins import FilterModifierMixin
|
||||||
from utilities.forms.widgets import FilterModifierWidget
|
from utilities.forms.widgets import FilterModifierWidget
|
||||||
from utilities.templatetags.helpers import applied_filters
|
from utilities.templatetags.helpers import applied_filters
|
||||||
|
from tenancy.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
# Test model for FilterModifierMixin tests
|
# Test model for FilterModifierMixin tests
|
||||||
@@ -99,6 +101,51 @@ class FilterModifierWidgetTest(TestCase):
|
|||||||
self.assertEqual(context['widget']['current_modifier'], 'exact') # Defaults to exact, JS updates from URL
|
self.assertEqual(context['widget']['current_modifier'], 'exact') # Defaults to exact, JS updates from URL
|
||||||
self.assertEqual(context['widget']['current_value'], 'test')
|
self.assertEqual(context['widget']['current_value'], 'test')
|
||||||
|
|
||||||
|
def test_get_context_handles_null_selection(self):
|
||||||
|
"""Widget should preserve the 'null' choice when rendering."""
|
||||||
|
|
||||||
|
null_value = settings.FILTERS_NULL_CHOICE_VALUE
|
||||||
|
null_label = settings.FILTERS_NULL_CHOICE_LABEL
|
||||||
|
|
||||||
|
# Simulate a query for objects with no tenant assigned (?tenant_id=null)
|
||||||
|
query_params = QueryDict(f'tenant_id={null_value}')
|
||||||
|
form = DeviceFilterForm(query_params)
|
||||||
|
|
||||||
|
# Rendering the field triggers FilterModifierWidget.get_context()
|
||||||
|
try:
|
||||||
|
html = form['tenant_id'].as_widget()
|
||||||
|
except ValueError as e:
|
||||||
|
# ValueError: Field 'id' expected a number but got 'null'
|
||||||
|
self.fail(f"FilterModifierWidget raised ValueError on 'null' selection: {e}")
|
||||||
|
|
||||||
|
# Verify the "None" option is rendered so user selection is preserved in the UI
|
||||||
|
self.assertIn(f'value="{null_value}"', html)
|
||||||
|
self.assertIn(null_label, html)
|
||||||
|
|
||||||
|
def test_get_context_handles_mixed_selection(self):
|
||||||
|
"""Widget should preserve both real objects and the 'null' choice together."""
|
||||||
|
|
||||||
|
null_value = settings.FILTERS_NULL_CHOICE_VALUE
|
||||||
|
|
||||||
|
# Create a tenant to simulate a real object
|
||||||
|
tenant = Tenant.objects.create(name='Tenant A', slug='tenant-a')
|
||||||
|
|
||||||
|
# Simulate a selection containing both a real PK and the null sentinel
|
||||||
|
query_params = QueryDict('', mutable=True)
|
||||||
|
query_params.setlist('tenant_id', [str(tenant.pk), null_value])
|
||||||
|
form = DeviceFilterForm(query_params)
|
||||||
|
|
||||||
|
# Rendering the field triggers FilterModifierWidget.get_context()
|
||||||
|
try:
|
||||||
|
html = form['tenant_id'].as_widget()
|
||||||
|
except ValueError as e:
|
||||||
|
# ValueError: Field 'id' expected a number but got 'null'
|
||||||
|
self.fail(f"FilterModifierWidget raised ValueError on 'null' selection: {e}")
|
||||||
|
|
||||||
|
# Verify both the real object and the null option are present in the output
|
||||||
|
self.assertIn(f'value="{tenant.pk}"', html)
|
||||||
|
self.assertIn(f'value="{null_value}"', html)
|
||||||
|
|
||||||
def test_widget_renders_modifier_dropdown_and_input(self):
|
def test_widget_renders_modifier_dropdown_and_input(self):
|
||||||
"""Widget should render modifier dropdown alongside original input."""
|
"""Widget should render modifier dropdown alongside original input."""
|
||||||
widget = FilterModifierWidget(
|
widget = FilterModifierWidget(
|
||||||
|
|||||||
Reference in New Issue
Block a user