Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion netbox/project-static/dist/netbox.css

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions netbox/project-static/dist/netbox.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions netbox/project-static/dist/netbox.js.map

Large diffs are not rendered by default.

63 changes: 52 additions & 11 deletions netbox/project-static/src/buttons/moveOptions.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,50 @@
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
*/
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
Expand All @@ -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
Expand All @@ -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);
}
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions netbox/project-static/styles/transitional/_forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
39 changes: 35 additions & 4 deletions netbox/users/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,41 @@ def save(self, *args, **kwargs):


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):
Expand Down
50 changes: 44 additions & 6 deletions netbox/utilities/forms/widgets/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -86,19 +99,44 @@ def get_context(self, name, value, attrs):

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):
"""
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
]
# 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):
"""
Expand Down
Loading