mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-27 07:37:45 -06:00
Merge ef29acc21e into c6672538ac
This commit is contained in:
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
8
netbox/project-static/dist/netbox.js
vendored
8
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user