diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 19bcf14d6..bda703cbe 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..b570e578b 100644 --- a/netbox/project-static/styles/transitional/_forms.scss +++ b/netbox/project-static/styles/transitional/_forms.scss @@ -32,3 +32,14 @@ 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-weight: bold; + padding: 0.25rem 0.5rem; + } +} diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 25db67ea8..b20dcbead 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -283,10 +283,41 @@ 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), ...]), ...] + """ + from django.apps import apps + + choices = [] + current_app = None + current_group = [] + + for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model'): + # Get verbose app label (e.g., "NetBox Branching" instead of "netbox_branching") + try: + app_config = apps.get_app_config(ot.app_label) + app_label = app_config.verbose_name + except LookupError: + app_label = ot.app_label + + # Start new optgroup when app changes + if current_app != app_label: + if current_group: + choices.append((current_app, current_group)) + current_app = app_label + current_group = [] + + # Add model to current group using just the model's verbose name + model_class = ot.model_class() + model_name = model_class._meta.verbose_name if model_class else ot.model + current_group.append((ot.pk, model_name.title())) + + # Add final group + if current_group: + choices.append((current_app, current_group)) + + return choices class ObjectPermissionForm(forms.ModelForm): diff --git a/netbox/utilities/forms/widgets/select.py b/netbox/utilities/forms/widgets/select.py index 7f4e9c87f..493d4081d 100644 --- a/netbox/utilities/forms/widgets/select.py +++ b/netbox/utilities/forms/widgets/select.py @@ -72,9 +72,22 @@ class AvailableOptions(forms.SelectMultiple): will be empty.) Employed by SplitMultiSelectWidget. """ def optgroups(self, name, value, attrs=None): - self.choices = [ - choice for choice in self.choices if str(choice[0]) not in value - ] + # Handle both flat choices and optgroup choices + filtered_choices = [] + for choice in self.choices: + # Check if this is an optgroup (nested tuple) or flat choice + if isinstance(choice[1], (list, tuple)): + # This is an optgroup: (group_label, [(id, name), ...]) + group_label, group_choices = choice + filtered_group = [c for c in group_choices if str(c[0]) not in value] + if filtered_group: # Only include optgroup if it has choices left + filtered_choices.append((group_label, filtered_group)) + else: + # This is a flat choice: (id, name) + if str(choice[0]) not in value: + filtered_choices.append(choice) + + self.choices = filtered_choices value = [] # Clear selected choices return super().optgroups(name, value, attrs) @@ -86,6 +99,12 @@ class AvailableOptions(forms.SelectMultiple): return context + def create_option(self, name, value, label, selected, index, subindex=None, attrs=None): + option = super().create_option(name, value, label, selected, index, subindex, attrs) + # Add title attribute to show full text on hover + option['attrs']['title'] = label + return option + class SelectedOptions(forms.SelectMultiple): """ @@ -93,12 +112,31 @@ class SelectedOptions(forms.SelectMultiple): 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 - ] + # Handle both flat choices and optgroup choices + filtered_choices = [] + for choice in self.choices: + # Check if this is an optgroup (nested tuple) or flat choice + if isinstance(choice[1], (list, tuple)): + # This is an optgroup: (group_label, [(id, name), ...]) + group_label, group_choices = choice + filtered_group = [c for c in group_choices if str(c[0]) in value] + if filtered_group: # Only include optgroup if it has choices left + filtered_choices.append((group_label, filtered_group)) + else: + # This is a flat choice: (id, name) + if str(choice[0]) in value: + 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) + # Add title attribute to show full text on hover + option['attrs']['title'] = label + return option + class SplitMultiSelectWidget(forms.MultiWidget): """