mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-13 03:49:36 -06:00
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
This commit is contained in:
parent
da1e0f4b53
commit
ef29acc21e
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -1,7 +1,7 @@
|
|||||||
import { getElements } from '../util';
|
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 source Select Element
|
||||||
* @param target Select Element
|
* @param target Select Element
|
||||||
@ -9,14 +9,42 @@ import { getElements } from '../util';
|
|||||||
function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
|
function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
|
||||||
for (const option of Array.from(source.options)) {
|
for (const option of Array.from(source.options)) {
|
||||||
if (option.selected) {
|
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();
|
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:
|
* Adapted from:
|
||||||
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
|
* @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++) {
|
for (let i = 1; i < options.length; i++) {
|
||||||
const option = options[i];
|
const option = options[i];
|
||||||
if (option.selected) {
|
if (option.selected) {
|
||||||
element.removeChild(option);
|
const parent = option.parentElement as HTMLElement;
|
||||||
element.insertBefore(option, element.options[i - 1]);
|
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:
|
* Adapted from:
|
||||||
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
|
* @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 {
|
function moveOptionDown(element: HTMLSelectElement): void {
|
||||||
const options = Array.from(element.options);
|
const options = Array.from(element.options);
|
||||||
for (let i = options.length - 2; i >= 0; i--) {
|
for (let i = options.length - 2; i >= 0; i--) {
|
||||||
let option = options[i];
|
const option = options[i];
|
||||||
if (option.selected) {
|
if (option.selected) {
|
||||||
let next = element.options[i + 1];
|
const parent = option.parentElement as HTMLElement;
|
||||||
option = element.removeChild(option);
|
const nextOption = element.options[i + 1];
|
||||||
next = element.replaceChild(option, next);
|
const nextParent = nextOption.parentElement as HTMLElement;
|
||||||
element.insertBefore(next, option);
|
|
||||||
|
// 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;
|
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():
|
def get_object_types_choices():
|
||||||
return [
|
"""
|
||||||
(ot.pk, str(ot))
|
Generate choices for object types grouped by app label using optgroups.
|
||||||
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model')
|
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):
|
class ObjectPermissionForm(forms.ModelForm):
|
||||||
|
|||||||
@ -72,9 +72,22 @@ class AvailableOptions(forms.SelectMultiple):
|
|||||||
will be empty.) Employed by SplitMultiSelectWidget.
|
will be empty.) Employed by SplitMultiSelectWidget.
|
||||||
"""
|
"""
|
||||||
def optgroups(self, name, value, attrs=None):
|
def optgroups(self, name, value, attrs=None):
|
||||||
self.choices = [
|
# Handle both flat choices and optgroup choices
|
||||||
choice for choice in self.choices if str(choice[0]) not in value
|
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
|
value = [] # Clear selected choices
|
||||||
return super().optgroups(name, value, attrs)
|
return super().optgroups(name, value, attrs)
|
||||||
|
|
||||||
@ -86,6 +99,12 @@ class AvailableOptions(forms.SelectMultiple):
|
|||||||
|
|
||||||
return context
|
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):
|
class SelectedOptions(forms.SelectMultiple):
|
||||||
"""
|
"""
|
||||||
@ -93,12 +112,31 @@ class SelectedOptions(forms.SelectMultiple):
|
|||||||
will include _all_ choices.) Employed by SplitMultiSelectWidget.
|
will include _all_ choices.) Employed by SplitMultiSelectWidget.
|
||||||
"""
|
"""
|
||||||
def optgroups(self, name, value, attrs=None):
|
def optgroups(self, name, value, attrs=None):
|
||||||
self.choices = [
|
# Handle both flat choices and optgroup choices
|
||||||
choice for choice in self.choices if str(choice[0]) in value
|
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
|
value = [] # Clear selected choices
|
||||||
return super().optgroups(name, value, attrs)
|
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):
|
class SplitMultiSelectWidget(forms.MultiWidget):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user