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

Fixes #20759: Group object types by app in permission form (#20931)

* Fixes #20759: Group object types by app in permission form

Modified the ObjectPermissionForm to use optgroups for organizing
object types by application. This shortens the display names (e.g.,
"permission" instead of "Authentication and Authorization | permission")
while maintaining clear organization through visual grouping.

Changes:
- Updated get_object_types_choices() to return nested optgroup structure
- Enhanced AvailableOptions and SelectedOptions widgets to handle optgroups
- Modified TypeScript moveOptions to preserve optgroup structure
- Added hover text showing full model names
- Styled optgroups with bold, padded labels

* Address PR feedback
Jason Novinger 2 месяцев назад
Родитель
Сommit
17e5184a11

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 52 - 11
netbox/project-static/src/buttons/moveOptions.ts

@@ -1,7 +1,7 @@
 import { getElements } from '../util';
 
 /**
- * Move selected options from one select element to another.
+ * Move selected options from one select element to another, preserving optgroup structure.
  *
  * @param source Select Element
  * @param target Select Element
@@ -9,14 +9,42 @@ import { getElements } from '../util';
 function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
   for (const option of Array.from(source.options)) {
     if (option.selected) {
-      target.appendChild(option.cloneNode(true));
+      // Check if option is inside an optgroup
+      const parentOptgroup = option.parentElement as HTMLElement;
+
+      if (parentOptgroup.tagName === 'OPTGROUP') {
+        // Find or create matching optgroup in target
+        const groupLabel = parentOptgroup.getAttribute('label');
+        let targetOptgroup = Array.from(target.children).find(
+          child => child.tagName === 'OPTGROUP' && child.getAttribute('label') === groupLabel,
+        ) as HTMLOptGroupElement;
+
+        if (!targetOptgroup) {
+          // Create new optgroup in target
+          targetOptgroup = document.createElement('optgroup');
+          targetOptgroup.setAttribute('label', groupLabel!);
+          target.appendChild(targetOptgroup);
+        }
+
+        // Move option to target optgroup
+        targetOptgroup.appendChild(option.cloneNode(true));
+      } else {
+        // Option is not in an optgroup, append directly
+        target.appendChild(option.cloneNode(true));
+      }
+
       option.remove();
+
+      // Clean up empty optgroups in source
+      if (parentOptgroup.tagName === 'OPTGROUP' && parentOptgroup.children.length === 0) {
+        parentOptgroup.remove();
+      }
     }
   }
 }
 
 /**
- * Move selected options of a select element up in order.
+ * Move selected options of a select element up in order, respecting optgroup boundaries.
  *
  * Adapted from:
  * @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
@@ -27,14 +55,21 @@ function moveOptionUp(element: HTMLSelectElement): void {
   for (let i = 1; i < options.length; i++) {
     const option = options[i];
     if (option.selected) {
-      element.removeChild(option);
-      element.insertBefore(option, element.options[i - 1]);
+      const parent = option.parentElement as HTMLElement;
+      const previousOption = element.options[i - 1];
+      const previousParent = previousOption.parentElement as HTMLElement;
+
+      // Only move if previous option is in the same parent (optgroup or select)
+      if (parent === previousParent) {
+        parent.removeChild(option);
+        parent.insertBefore(option, previousOption);
+      }
     }
   }
 }
 
 /**
- * Move selected options of a select element down in order.
+ * Move selected options of a select element down in order, respecting optgroup boundaries.
  *
  * Adapted from:
  * @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
@@ -43,12 +78,18 @@ function moveOptionUp(element: HTMLSelectElement): void {
 function moveOptionDown(element: HTMLSelectElement): void {
   const options = Array.from(element.options);
   for (let i = options.length - 2; i >= 0; i--) {
-    let option = options[i];
+    const option = options[i];
     if (option.selected) {
-      let next = element.options[i + 1];
-      option = element.removeChild(option);
-      next = element.replaceChild(option, next);
-      element.insertBefore(next, option);
+      const parent = option.parentElement as HTMLElement;
+      const nextOption = element.options[i + 1];
+      const nextParent = nextOption.parentElement as HTMLElement;
+
+      // Only move if next option is in the same parent (optgroup or select)
+      if (parent === nextParent) {
+        const optionClone = parent.removeChild(option);
+        const nextClone = parent.replaceChild(optionClone, nextOption);
+        parent.insertBefore(nextClone, optionClone);
+      }
     }
   }
 }

+ 14 - 0
netbox/project-static/styles/transitional/_forms.scss

@@ -32,3 +32,17 @@ form.object-edit {
     border: 1px solid $red;
   }
 }
+
+// Make optgroup labels sticky when scrolling through select elements
+select[multiple] {
+  optgroup {
+    position: sticky;
+    top: 0;
+    background-color: var(--bs-body-bg);
+    font-style: normal;
+    font-weight: bold;
+  }
+  option {
+    padding-left: 0.5rem;
+  }
+}

+ 21 - 4
netbox/users/forms/model_forms.py

@@ -1,6 +1,8 @@
 import json
+from collections import defaultdict
 
 from django import forms
+from django.apps import apps
 from django.conf import settings
 from django.contrib.auth import password_validation
 from django.contrib.postgres.forms import SimpleArrayField
@@ -21,6 +23,7 @@ from utilities.forms.fields import (
     DynamicModelMultipleChoiceField,
     JSONField,
 )
+from utilities.string import title
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
 from utilities.permissions import qs_filter_from_constraints
@@ -283,10 +286,24 @@ class GroupForm(forms.ModelForm):
 
 
 def get_object_types_choices():
-    return [
-        (ot.pk, str(ot))
-        for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model')
-    ]
+    """
+    Generate choices for object types grouped by app label using optgroups.
+    Returns nested structure: [(app_label, [(id, model_name), ...]), ...]
+    """
+    app_label_map = {
+        app_config.label: app_config.verbose_name
+        for app_config in apps.get_app_configs()
+    }
+    choices_by_app = defaultdict(list)
+
+    for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model'):
+        app_label = app_label_map.get(ot.app_label, ot.app_label)
+
+        model_class = ot.model_class()
+        model_name = model_class._meta.verbose_name if model_class else ot.model
+        choices_by_app[app_label].append((ot.pk, title(model_name)))
+
+    return list(choices_by_app.items())
 
 
 class ObjectPermissionForm(forms.ModelForm):

+ 36 - 13
netbox/utilities/forms/widgets/select.py

@@ -66,18 +66,46 @@ class SelectWithPK(forms.Select):
     option_template_name = 'widgets/select_option_with_pk.html'
 
 
-class AvailableOptions(forms.SelectMultiple):
+class SelectMultipleBase(forms.SelectMultiple):
     """
-    Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
-    will be empty.) Employed by SplitMultiSelectWidget.
+    Base class for select widgets that filter choices based on selected values.
+    Subclasses should set `include_selected` to control filtering behavior.
     """
+    include_selected = False
+
     def optgroups(self, name, value, attrs=None):
-        self.choices = [
-            choice for choice in self.choices if str(choice[0]) not in value
-        ]
+        filtered_choices = []
+        include_selected = self.include_selected
+
+        for choice in self.choices:
+            if isinstance(choice[1], (list, tuple)):  # optgroup
+                group_label, group_choices = choice
+                filtered_group = [
+                    c for c in group_choices if (str(c[0]) in value) == include_selected
+                ]
+
+                if filtered_group:  # Only include optgroup if it has choices left
+                    filtered_choices.append((group_label, filtered_group))
+            else:  # option, e.g. flat choice
+                if (str(choice[0]) in value) == include_selected:
+                    filtered_choices.append(choice)
+
+        self.choices = filtered_choices
         value = []  # Clear selected choices
         return super().optgroups(name, value, attrs)
 
+    def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
+        option = super().create_option(name, value, label, selected, index, subindex, attrs)
+        option['attrs']['title'] = label  # Add title attribute to show full text on hover
+        return option
+
+
+class AvailableOptions(SelectMultipleBase):
+    """
+    Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
+    will be empty.) Employed by SplitMultiSelectWidget.
+    """
+
     def get_context(self, name, value, attrs):
         context = super().get_context(name, value, attrs)
 
@@ -87,17 +115,12 @@ class AvailableOptions(forms.SelectMultiple):
         return context
 
 
-class SelectedOptions(forms.SelectMultiple):
+class SelectedOptions(SelectMultipleBase):
     """
     Renders a <select multiple=true> including only choices that have _not_ been selected. (For unbound fields, this
     will include _all_ choices.) Employed by SplitMultiSelectWidget.
     """
-    def optgroups(self, name, value, attrs=None):
-        self.choices = [
-            choice for choice in self.choices if str(choice[0]) in value
-        ]
-        value = []  # Clear selected choices
-        return super().optgroups(name, value, attrs)
+    include_selected = True
 
 
 class SplitMultiSelectWidget(forms.MultiWidget):

+ 69 - 0
netbox/utilities/tests/test_forms.py

@@ -7,6 +7,7 @@ from utilities.forms.bulk_import import BulkImportForm
 from utilities.forms.fields.csv import CSVSelectWidget
 from utilities.forms.forms import BulkRenameForm
 from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern
+from utilities.forms.widgets.select import AvailableOptions, SelectedOptions
 
 
 class ExpandIPAddress(TestCase):
@@ -481,3 +482,71 @@ class CSVSelectWidgetTest(TestCase):
         widget = CSVSelectWidget()
         data = {'test_field': 'valid_value'}
         self.assertFalse(widget.value_omitted_from_data(data, {}, 'test_field'))
+
+
+class SelectMultipleWidgetTest(TestCase):
+    """
+    Validate filtering behavior of AvailableOptions and SelectedOptions widgets.
+    """
+
+    def test_available_options_flat_choices(self):
+        """AvailableOptions should exclude selected values from flat choices"""
+        widget = AvailableOptions(choices=[
+            (1, 'Option 1'),
+            (2, 'Option 2'),
+            (3, 'Option 3'),
+        ])
+        widget.optgroups('test', ['2'], None)
+
+        self.assertEqual(len(widget.choices), 2)
+        self.assertEqual(widget.choices[0], (1, 'Option 1'))
+        self.assertEqual(widget.choices[1], (3, 'Option 3'))
+
+    def test_available_options_optgroups(self):
+        """AvailableOptions should exclude selected values from optgroups"""
+        widget = AvailableOptions(choices=[
+            ('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
+            ('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
+        ])
+
+        # Select options 2 and 3
+        widget.optgroups('test', ['2', '3'], None)
+
+        # Should have 2 groups with filtered choices
+        self.assertEqual(len(widget.choices), 2)
+        self.assertEqual(widget.choices[0][0], 'Group A')
+        self.assertEqual(widget.choices[0][1], [(1, 'Option 1')])
+        self.assertEqual(widget.choices[1][0], 'Group B')
+        self.assertEqual(widget.choices[1][1], [(4, 'Option 4')])
+
+    def test_selected_options_flat_choices(self):
+        """SelectedOptions should include only selected values from flat choices"""
+        widget = SelectedOptions(choices=[
+            (1, 'Option 1'),
+            (2, 'Option 2'),
+            (3, 'Option 3'),
+        ])
+
+        # Select option 2
+        widget.optgroups('test', ['2'], None)
+
+        # Should only have option 2
+        self.assertEqual(len(widget.choices), 1)
+        self.assertEqual(widget.choices[0], (2, 'Option 2'))
+
+    def test_selected_options_optgroups(self):
+        """SelectedOptions should include only selected values from optgroups"""
+        widget = SelectedOptions(choices=[
+            ('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
+            ('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
+        ])
+
+        # Select options 2 and 3
+        widget.optgroups('test', ['2', '3'], None)
+
+        # Should have 2 groups with only selected choices
+        self.assertEqual(len(widget.choices), 2)
+        self.assertEqual(widget.choices[0][0], 'Group A')
+        self.assertEqual(widget.choices[0][1], [(2, 'Option 2')])
+        self.assertEqual(widget.choices[1][0], 'Group B')
+        self.assertEqual(widget.choices[1][1], [(3, 'Option 3')])

Некоторые файлы не были показаны из-за большого количества измененных файлов