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

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.
Martin Hauser 3 недель назад
Родитель
Сommit
6d166aa10d

+ 17 - 10
netbox/utilities/forms/widgets/modifiers.py

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

+ 47 - 0
netbox/utilities/tests/test_filter_modifiers.py

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