diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 19bcf14d6..001c8d1ea 100644 Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 095469011..32c552b74 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 2429b2c4c..6ea877d79 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/buttons/moveOptions.ts b/netbox/project-static/src/buttons/moveOptions.ts index a98bf732a..2b7e39a16 100644 --- a/netbox/project-static/src/buttons/moveOptions.ts +++ b/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); + } } } } diff --git a/netbox/project-static/styles/transitional/_forms.scss b/netbox/project-static/styles/transitional/_forms.scss index 147b11b97..668f60e2a 100644 --- a/netbox/project-static/styles/transitional/_forms.scss +++ b/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; + } +} diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 25db67ea8..d5f5bbbc0 100644 --- a/netbox/users/forms/model_forms.py +++ b/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): diff --git a/netbox/utilities/forms/widgets/select.py b/netbox/utilities/forms/widgets/select.py index 7f4e9c87f..b3197a922 100644 --- a/netbox/utilities/forms/widgets/select.py +++ b/netbox/utilities/forms/widgets/select.py @@ -66,17 +66,45 @@ class SelectWithPK(forms.Select): option_template_name = 'widgets/select_option_with_pk.html' -class AvailableOptions(forms.SelectMultiple): +class SelectMultipleBase(forms.SelectMultiple): + """ + 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): + 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 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): diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index cc3ebbb71..6e523c271 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/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')])