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.conf import settings
from django.utils.translation import gettext_lazy as _
from utilities.forms.widgets.apiselect import APISelect, APISelectMultiple
@@ -101,21 +102,27 @@ class FilterModifierWidget(forms.Widget):
if isinstance(self.original_widget, (APISelect, APISelectMultiple)):
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:
values = value if isinstance(value, (list, tuple)) else [value]
if hasattr(original_choices, 'queryset'):
queryset = original_choices.queryset
selected_objects = queryset.filter(pk__in=values)
# Build minimal choice list with just the selected values
self.original_widget.choices = [
(obj.pk, str(obj)) for obj in selected_objects
]
# Extract valid PKs (exclude special null choice string)
pk_values = [v for v in values if v != settings.FILTERS_NULL_CHOICE_VALUE]
# Build a minimal choice list with just the selected values
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:
self.original_widget.choices = [
choice for choice in original_choices if choice[0] in values
]
self.original_widget.choices = [choice for choice in original_choices if choice[0] in values]
else:
# No selection - render empty select element
self.original_widget.choices = []

View File

@@ -1,4 +1,5 @@
from django import forms
from django.conf import settings
from django.db import models
from django.http import QueryDict
from django.template import Context
@@ -14,6 +15,7 @@ from utilities.forms.fields import TagFilterField
from utilities.forms.mixins import FilterModifierMixin
from utilities.forms.widgets import FilterModifierWidget
from utilities.templatetags.helpers import applied_filters
from tenancy.models import Tenant
# 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_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):
"""Widget should render modifier dropdown alongside original input."""
widget = FilterModifierWidget(