feat(utilities): Handle "null" choice selection in widgets

Enhances widget handling by preserving "null" choice values in both
individual and mixed-object selections. Updates tests to validate UI
rendering and ensure compatibility with null sentinel values.
This commit is contained in:
Martin Hauser
2026-01-20 14:05:45 +01:00
parent 040a2ae9a9
commit 6d166aa10d
2 changed files with 64 additions and 10 deletions

View File

@@ -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 = []

View File

@@ -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(