fix: replace sidebar controls with resize handle
This commit is contained in:
@@ -32,7 +32,7 @@ Release-Tag oder ein bestimmter Commit verwendet werden:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
|
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
|
||||||
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.5.0"
|
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.5.1"
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
|
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
|
||||||
@@ -202,14 +202,13 @@ Unter **Plugins > NetBox Utilities > Navigation personalisieren** sieht der
|
|||||||
Benutzer alle Menüs, für die er aktuell Berechtigungen besitzt. Die Pfeiltasten
|
Benutzer alle Menüs, für die er aktuell Berechtigungen besitzt. Die Pfeiltasten
|
||||||
ändern die Reihenfolge; der Schalter blendet ein Menü aus.
|
ändern die Reihenfolge; der Schalter blendet ein Menü aus.
|
||||||
|
|
||||||
Zusätzlich kann jeder Benutzer die Desktop-Navigation auf einen reinen
|
Zusätzlich kann jeder Benutzer die Desktop-Navigation direkt an der rechten
|
||||||
**Icon-Modus** einklappen und die Breite der ausgeklappten Navigation zwischen
|
Kante zwischen 216 und 408 Pixeln breiter oder schmaler ziehen. Ein kleiner
|
||||||
216 und 408 Pixeln einstellen. Am unteren Rand der Navigation stehen dafür
|
Pfeilgriff in der Mitte dieser Kante klappt sie auf einen reinen **Icon-Modus**
|
||||||
direkte Schaltflächen für **schmaler**, **einklappen/ausklappen** und **breiter**
|
ein und wieder aus. Dafür gibt es kein zusätzliches Bedienmenü. Beim Ziehen
|
||||||
zur Verfügung. Änderungen über diese Schaltflächen werden sofort im
|
wird die neue Breite nach dem Loslassen im Benutzerprofil gespeichert. Im
|
||||||
Benutzerprofil gespeichert. Im Icon-Modus öffnen sich die Menüinhalte als
|
Icon-Modus öffnen sich die Menüinhalte als seitliches Flyout; auf kleinen
|
||||||
seitliches Flyout; auf kleinen beziehungsweise mobilen Ansichten bleibt das
|
beziehungsweise mobilen Ansichten bleibt das normale NetBox-Menü erhalten.
|
||||||
normale NetBox-Menü erhalten.
|
|
||||||
|
|
||||||
**Zurücksetzen** stellt neben Reihenfolge und Sichtbarkeit auch die normale
|
**Zurücksetzen** stellt neben Reihenfolge und Sichtbarkeit auch die normale
|
||||||
ausgeklappte Breite von 288 Pixeln wieder her.
|
ausgeklappte Breite von 288 Pixeln wieder her.
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from netbox.plugins import PluginConfig, get_plugin_config
|
from netbox.plugins import PluginConfig, get_plugin_config
|
||||||
|
|
||||||
|
__version__ = "0.5.1"
|
||||||
|
|
||||||
|
|
||||||
class NetBoxUtilitiesConfig(PluginConfig):
|
class NetBoxUtilitiesConfig(PluginConfig):
|
||||||
name = "netbox_utilities"
|
name = "netbox_utilities"
|
||||||
verbose_name = "NetBox Utilities"
|
verbose_name = "NetBox Utilities"
|
||||||
description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering"
|
description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering"
|
||||||
version = "0.5.0"
|
version = __version__
|
||||||
author = "LKE"
|
author = "LKE"
|
||||||
base_url = "utilities"
|
base_url = "utilities"
|
||||||
min_version = "4.6.5"
|
min_version = "4.6.5"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from netbox.navigation.menu import get_menus
|
|||||||
|
|
||||||
SIDEBAR_WIDTH_MIN = 216
|
SIDEBAR_WIDTH_MIN = 216
|
||||||
SIDEBAR_WIDTH_MAX = 408
|
SIDEBAR_WIDTH_MAX = 408
|
||||||
SIDEBAR_WIDTH_STEP = 24
|
|
||||||
SIDEBAR_WIDTH_DEFAULT = 288
|
SIDEBAR_WIDTH_DEFAULT = 288
|
||||||
|
|
||||||
|
|
||||||
@@ -12,19 +11,15 @@ def normalize_sidebar_width(value):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return SIDEBAR_WIDTH_DEFAULT
|
return SIDEBAR_WIDTH_DEFAULT
|
||||||
|
|
||||||
width = min(SIDEBAR_WIDTH_MAX, max(SIDEBAR_WIDTH_MIN, width))
|
return min(SIDEBAR_WIDTH_MAX, max(SIDEBAR_WIDTH_MIN, width))
|
||||||
steps = (width - SIDEBAR_WIDTH_MIN + SIDEBAR_WIDTH_STEP // 2) // SIDEBAR_WIDTH_STEP
|
|
||||||
return SIDEBAR_WIDTH_MIN + steps * SIDEBAR_WIDTH_STEP
|
|
||||||
|
|
||||||
|
|
||||||
def update_sidebar_layout(collapsed, width, action):
|
def update_sidebar_layout(collapsed, width, action, requested_width=None):
|
||||||
width = normalize_sidebar_width(width)
|
width = normalize_sidebar_width(width)
|
||||||
if action == "toggle":
|
if action == "toggle":
|
||||||
return not collapsed, width
|
return not collapsed, width
|
||||||
if action == "smaller":
|
if action == "resize":
|
||||||
return collapsed, max(SIDEBAR_WIDTH_MIN, width - SIDEBAR_WIDTH_STEP)
|
return False, normalize_sidebar_width(requested_width)
|
||||||
if action == "larger":
|
|
||||||
return collapsed, min(SIDEBAR_WIDTH_MAX, width + SIDEBAR_WIDTH_STEP)
|
|
||||||
raise ValueError("Unbekannte Navigationsaktion.")
|
raise ValueError("Unbekannte Navigationsaktion.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,9 @@
|
|||||||
function normalizeWidth(value) {
|
function normalizeWidth(value) {
|
||||||
const minimum = Number(preferences.width_min) || 216;
|
const minimum = Number(preferences.width_min) || 216;
|
||||||
const maximum = Number(preferences.width_max) || 408;
|
const maximum = Number(preferences.width_max) || 408;
|
||||||
const step = Number(preferences.width_step) || 24;
|
const requested = Number(value);
|
||||||
const requested = Number(value) || 288;
|
const width = Number.isFinite(requested) ? Math.round(requested) : 288;
|
||||||
const snapped = minimum + Math.round((requested - minimum) / step) * step;
|
return Math.min(maximum, Math.max(minimum, width));
|
||||||
return Math.min(maximum, Math.max(minimum, snapped));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRootLayout() {
|
function applyRootLayout() {
|
||||||
@@ -58,7 +57,8 @@
|
|||||||
sidebar.querySelectorAll(':scope > li.nav-item.dropdown > button.nav-link').forEach((button) => {
|
sidebar.querySelectorAll(':scope > li.nav-item.dropdown > button.nav-link').forEach((button) => {
|
||||||
if (preferences.collapsed) {
|
if (preferences.collapsed) {
|
||||||
button.dataset.netboxUtilitiesTitle = 'true';
|
button.dataset.netboxUtilitiesTitle = 'true';
|
||||||
button.title = button.getAttribute('aria-label') || '';
|
button.title =
|
||||||
|
button.getAttribute('aria-label') || button.querySelector('.nav-link-title')?.textContent.trim() || '';
|
||||||
const menu = button.closest('.nav-item.dropdown')?.querySelector(':scope > .dropdown-menu.show');
|
const menu = button.closest('.nav-item.dropdown')?.querySelector(':scope > .dropdown-menu.show');
|
||||||
if (menu) {
|
if (menu) {
|
||||||
const top = Math.max(8, button.getBoundingClientRect().top);
|
const top = Math.max(8, button.getBoundingClientRect().top);
|
||||||
@@ -94,111 +94,149 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createControl(action, icon, label) {
|
async function saveLayout(action, width) {
|
||||||
const button = document.createElement('button');
|
const body = new URLSearchParams({action});
|
||||||
button.type = 'button';
|
if (width !== undefined) body.set('width', String(width));
|
||||||
button.className = 'btn btn-sm btn-ghost-secondary netbox-utilities-sidebar-control';
|
const response = await fetch(preferences.layout_update_url, {
|
||||||
button.dataset.action = action;
|
method: 'POST',
|
||||||
button.title = label;
|
credentials: 'same-origin',
|
||||||
button.setAttribute('aria-label', label);
|
headers: {
|
||||||
const iconElement = document.createElement('i');
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||||
iconElement.className = `mdi ${icon}`;
|
'X-CSRFToken': preferences.csrf_token,
|
||||||
iconElement.setAttribute('aria-hidden', 'true');
|
},
|
||||||
button.appendChild(iconElement);
|
body,
|
||||||
return button;
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateControls(controls, sidebar, pending = false) {
|
function addResizeHandle(sidebarElement, sidebar) {
|
||||||
const minimum = Number(preferences.width_min) || 216;
|
|
||||||
const maximum = Number(preferences.width_max) || 408;
|
|
||||||
const smaller = controls.querySelector('[data-action="smaller"]');
|
|
||||||
const larger = controls.querySelector('[data-action="larger"]');
|
|
||||||
const toggle = controls.querySelector('[data-action="toggle"]');
|
|
||||||
const toggleIcon = toggle.querySelector('i');
|
|
||||||
|
|
||||||
smaller.disabled = pending || preferences.collapsed || preferences.width <= minimum;
|
|
||||||
larger.disabled = pending || preferences.collapsed || preferences.width >= maximum;
|
|
||||||
toggle.disabled = pending;
|
|
||||||
toggleIcon.className = preferences.collapsed
|
|
||||||
? 'mdi mdi-arrow-expand-right'
|
|
||||||
: 'mdi mdi-arrow-collapse-left';
|
|
||||||
const toggleLabel = preferences.collapsed ? 'Navigation ausklappen' : 'Navigation auf Icons einklappen';
|
|
||||||
toggle.title = toggleLabel;
|
|
||||||
toggle.setAttribute('aria-label', toggleLabel);
|
|
||||||
updateMenuTitles(sidebar);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAction(action) {
|
|
||||||
const step = Number(preferences.width_step) || 24;
|
|
||||||
if (action === 'toggle') preferences.collapsed = !preferences.collapsed;
|
|
||||||
if (action === 'smaller') preferences.width = normalizeWidth(preferences.width - step);
|
|
||||||
if (action === 'larger') preferences.width = normalizeWidth(preferences.width + step);
|
|
||||||
applyRootLayout();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLayoutControls(sidebarMenu, sidebar) {
|
|
||||||
if (!preferences.layout_update_url || !preferences.csrf_token) return;
|
if (!preferences.layout_update_url || !preferences.csrf_token) return;
|
||||||
|
|
||||||
const controls = document.createElement('div');
|
const handle = document.createElement('div');
|
||||||
controls.className = 'netbox-utilities-sidebar-controls';
|
handle.className = 'netbox-utilities-sidebar-resizer';
|
||||||
controls.setAttribute('role', 'group');
|
handle.tabIndex = 0;
|
||||||
controls.setAttribute('aria-label', 'Navigationsgröße');
|
handle.setAttribute('role', 'separator');
|
||||||
controls.append(
|
handle.setAttribute('aria-orientation', 'vertical');
|
||||||
createControl('smaller', 'mdi-minus', 'Navigation schmaler'),
|
handle.setAttribute('aria-label', 'Breite der Navigation ändern');
|
||||||
createControl('toggle', 'mdi-arrow-collapse-left', 'Navigation auf Icons einklappen'),
|
|
||||||
createControl('larger', 'mdi-plus', 'Navigation breiter')
|
|
||||||
);
|
|
||||||
|
|
||||||
const releaseInfo = sidebarMenu.querySelector(':scope > .text-muted');
|
const toggle = document.createElement('button');
|
||||||
sidebarMenu.insertBefore(controls, releaseInfo || null);
|
toggle.type = 'button';
|
||||||
updateControls(controls, sidebar);
|
toggle.className = 'netbox-utilities-sidebar-toggle';
|
||||||
|
const toggleIcon = document.createElement('i');
|
||||||
|
toggleIcon.className = 'mdi';
|
||||||
|
toggleIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
toggle.appendChild(toggleIcon);
|
||||||
|
handle.appendChild(toggle);
|
||||||
|
sidebarElement.appendChild(handle);
|
||||||
|
|
||||||
controls.addEventListener('click', async (event) => {
|
let resizing = false;
|
||||||
const button = event.target.closest('button[data-action]');
|
let previousLayout = null;
|
||||||
if (!button || button.disabled) return;
|
|
||||||
|
|
||||||
const previous = {collapsed: preferences.collapsed, width: preferences.width};
|
function updateHandle() {
|
||||||
applyAction(button.dataset.action);
|
const minimum = Number(preferences.width_min) || 216;
|
||||||
updateControls(controls, sidebar, true);
|
const maximum = Number(preferences.width_max) || 408;
|
||||||
|
handle.setAttribute('aria-valuemin', String(minimum));
|
||||||
|
handle.setAttribute('aria-valuemax', String(maximum));
|
||||||
|
handle.setAttribute('aria-valuenow', String(preferences.collapsed ? minimum : preferences.width));
|
||||||
|
const label = preferences.collapsed ? 'Navigation ausklappen' : 'Navigation auf Icons einklappen';
|
||||||
|
toggle.title = label;
|
||||||
|
toggle.setAttribute('aria-label', label);
|
||||||
|
toggleIcon.className = preferences.collapsed ? 'mdi mdi-chevron-right' : 'mdi mdi-chevron-left';
|
||||||
|
updateMenuTitles(sidebar);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWidth(clientX) {
|
||||||
|
preferences.collapsed = false;
|
||||||
|
preferences.width = normalizeWidth(clientX);
|
||||||
|
applyRootLayout();
|
||||||
|
updateHandle();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(action, width, fallback) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(preferences.layout_update_url, {
|
const saved = await saveLayout(action, width);
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
||||||
'X-CSRFToken': preferences.csrf_token,
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({action: button.dataset.action}),
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
||||||
const saved = await response.json();
|
|
||||||
preferences.collapsed = Boolean(saved.collapsed);
|
preferences.collapsed = Boolean(saved.collapsed);
|
||||||
preferences.width = normalizeWidth(saved.width);
|
preferences.width = normalizeWidth(saved.width);
|
||||||
controls.classList.remove('text-danger');
|
handle.classList.remove('netbox-utilities-sidebar-save-error');
|
||||||
controls.removeAttribute('title');
|
handle.removeAttribute('data-save-error');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
preferences.collapsed = previous.collapsed;
|
preferences.collapsed = fallback.collapsed;
|
||||||
preferences.width = previous.width;
|
preferences.width = fallback.width;
|
||||||
controls.classList.add('text-danger');
|
handle.classList.add('netbox-utilities-sidebar-save-error');
|
||||||
controls.title = 'Die Navigationseinstellung konnte nicht gespeichert werden.';
|
handle.dataset.saveError = 'Die Navigationseinstellung konnte nicht gespeichert werden.';
|
||||||
console.error('Could not save NetBox navigation layout', error);
|
console.error('Could not save NetBox navigation layout', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyRootLayout();
|
applyRootLayout();
|
||||||
updateControls(controls, sidebar);
|
updateHandle();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle.addEventListener('click', async () => {
|
||||||
|
if (toggle.disabled) return;
|
||||||
|
const previous = {collapsed: preferences.collapsed, width: preferences.width};
|
||||||
|
preferences.collapsed = !preferences.collapsed;
|
||||||
|
applyRootLayout();
|
||||||
|
updateHandle();
|
||||||
|
toggle.disabled = true;
|
||||||
|
await persist('toggle', undefined, previous);
|
||||||
|
toggle.disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
handle.addEventListener('pointerdown', (event) => {
|
||||||
|
if (event.target.closest('button')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
resizing = true;
|
||||||
|
previousLayout = {collapsed: preferences.collapsed, width: preferences.width};
|
||||||
|
handle.setPointerCapture(event.pointerId);
|
||||||
|
root.classList.add('netbox-utilities-navigation-resizing');
|
||||||
|
applyWidth(event.clientX);
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.addEventListener('pointermove', (event) => {
|
||||||
|
if (resizing) applyWidth(event.clientX);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function finishResize(event) {
|
||||||
|
if (!resizing) return;
|
||||||
|
resizing = false;
|
||||||
|
if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId);
|
||||||
|
root.classList.remove('netbox-utilities-navigation-resizing');
|
||||||
|
const fallback = previousLayout;
|
||||||
|
previousLayout = null;
|
||||||
|
await persist('resize', preferences.width, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.addEventListener('pointerup', finishResize);
|
||||||
|
handle.addEventListener('pointercancel', finishResize);
|
||||||
|
|
||||||
|
handle.addEventListener('keydown', async (event) => {
|
||||||
|
const minimum = Number(preferences.width_min) || 216;
|
||||||
|
const maximum = Number(preferences.width_max) || 408;
|
||||||
|
const directions = {ArrowLeft: -16, ArrowRight: 16};
|
||||||
|
let width;
|
||||||
|
if (event.key in directions) width = preferences.width + directions[event.key];
|
||||||
|
if (event.key === 'Home') width = minimum;
|
||||||
|
if (event.key === 'End') width = maximum;
|
||||||
|
if (width === undefined) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const previous = {collapsed: preferences.collapsed, width: preferences.width};
|
||||||
|
applyWidth(width);
|
||||||
|
await persist('resize', preferences.width, previous);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateHandle();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeNavigation() {
|
function initializeNavigation() {
|
||||||
const sidebarMenu = document.getElementById('sidebar-menu');
|
const sidebarMenu = document.getElementById('sidebar-menu');
|
||||||
const sidebar = document.querySelector('#sidebar-menu > ul.navbar-nav');
|
const sidebar = document.querySelector('#sidebar-menu > ul.navbar-nav');
|
||||||
if (!sidebarMenu || !sidebar) return;
|
const sidebarElement = document.querySelector('.page > aside.navbar-vertical.navbar-expand-lg');
|
||||||
|
if (!sidebarMenu || !sidebar || !sidebarElement) return;
|
||||||
|
|
||||||
applyMenuPreferences(sidebar);
|
applyMenuPreferences(sidebar);
|
||||||
updateMenuTitles(sidebar);
|
updateMenuTitles(sidebar);
|
||||||
configureCollapsedDropdowns(sidebar);
|
configureCollapsedDropdowns(sidebar);
|
||||||
addLayoutControls(sidebarMenu, sidebar);
|
addResizeHandle(sidebarElement, sidebar);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyRootLayout();
|
applyRootLayout();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.netbox-utilities-sidebar-controls {
|
.netbox-utilities-sidebar-resizer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,24 +38,77 @@
|
|||||||
width: var(--netbox-utilities-sidebar-effective-width);
|
width: var(--netbox-utilities-sidebar-effective-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
.netbox-utilities-sidebar-controls {
|
.netbox-utilities-sidebar-resizer {
|
||||||
position: sticky;
|
position: fixed;
|
||||||
bottom: 0.5rem;
|
top: 0;
|
||||||
z-index: 2;
|
bottom: 0;
|
||||||
display: flex;
|
left: calc(var(--netbox-utilities-sidebar-effective-width) - 5px);
|
||||||
justify-content: center;
|
z-index: 1045;
|
||||||
gap: 0.25rem;
|
display: block;
|
||||||
margin: 0.75rem;
|
width: 10px;
|
||||||
padding: 0.35rem;
|
cursor: col-resize;
|
||||||
border: 1px solid var(--tblr-border-color-translucent);
|
touch-action: none;
|
||||||
border-radius: var(--tblr-border-radius);
|
outline: 0;
|
||||||
background: var(--tblr-bg-surface);
|
transition: left 160ms ease;
|
||||||
box-shadow: var(--tblr-box-shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.netbox-utilities-sidebar-control {
|
.netbox-utilities-sidebar-resizer::before {
|
||||||
flex: 0 1 3rem;
|
position: absolute;
|
||||||
padding-inline: 0.5rem;
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 4px;
|
||||||
|
width: 2px;
|
||||||
|
content: '';
|
||||||
|
background: transparent;
|
||||||
|
transition: background-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.netbox-utilities-sidebar-resizer:hover::before,
|
||||||
|
.netbox-utilities-sidebar-resizer:focus-visible::before,
|
||||||
|
html.netbox-utilities-navigation-resizing .netbox-utilities-sidebar-resizer::before {
|
||||||
|
background: var(--tblr-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.netbox-utilities-sidebar-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: grid;
|
||||||
|
width: 24px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--tblr-secondary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--tblr-bg-surface);
|
||||||
|
border: 1px solid var(--tblr-border-color);
|
||||||
|
border-radius: 0 var(--tblr-border-radius) var(--tblr-border-radius) 0;
|
||||||
|
box-shadow: var(--tblr-box-shadow-sm);
|
||||||
|
transform: translate(0, -50%);
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.netbox-utilities-sidebar-toggle:hover,
|
||||||
|
.netbox-utilities-sidebar-toggle:focus-visible {
|
||||||
|
color: var(--tblr-primary);
|
||||||
|
border-color: var(--tblr-primary);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.netbox-utilities-sidebar-save-error::before {
|
||||||
|
background: var(--tblr-danger) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.netbox-utilities-navigation-resizing,
|
||||||
|
html.netbox-utilities-navigation-resizing body {
|
||||||
|
cursor: col-resize !important;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.netbox-utilities-navigation-resizing .page > aside.navbar-vertical.navbar-expand-lg,
|
||||||
|
html.netbox-utilities-navigation-resizing .page > aside.navbar-vertical.navbar-expand-lg ~ .navbar,
|
||||||
|
html.netbox-utilities-navigation-resizing .page > aside.navbar-vertical.navbar-expand-lg ~ .page-wrapper,
|
||||||
|
html.netbox-utilities-navigation-resizing .netbox-utilities-sidebar-resizer {
|
||||||
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.netbox-utilities-navigation-collapsed .navbar-vertical.navbar-expand-lg > .container-fluid {
|
html.netbox-utilities-navigation-collapsed .navbar-vertical.navbar-expand-lg > .container-fluid {
|
||||||
@@ -105,19 +158,17 @@
|
|||||||
box-shadow: var(--tblr-box-shadow-lg);
|
box-shadow: var(--tblr-box-shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.netbox-utilities-navigation-collapsed .netbox-utilities-sidebar-controls {
|
html.netbox-utilities-navigation-collapsed .netbox-utilities-sidebar-resizer {
|
||||||
margin-inline: 0;
|
cursor: e-resize;
|
||||||
}
|
|
||||||
|
|
||||||
html.netbox-utilities-navigation-collapsed .netbox-utilities-sidebar-control:not([data-action="toggle"]) {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg,
|
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg,
|
||||||
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .navbar,
|
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .navbar,
|
||||||
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .page-wrapper {
|
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .page-wrapper,
|
||||||
|
.netbox-utilities-sidebar-resizer,
|
||||||
|
.netbox-utilities-sidebar-resizer::before {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ from django.urls import reverse
|
|||||||
from netbox.plugins import PluginTemplateExtension
|
from netbox.plugins import PluginTemplateExtension
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
from .models import NavigationPreference
|
from .models import NavigationPreference
|
||||||
from .navigation_helpers import (
|
from .navigation_helpers import (
|
||||||
SIDEBAR_WIDTH_DEFAULT,
|
SIDEBAR_WIDTH_DEFAULT,
|
||||||
SIDEBAR_WIDTH_MAX,
|
SIDEBAR_WIDTH_MAX,
|
||||||
SIDEBAR_WIDTH_MIN,
|
SIDEBAR_WIDTH_MIN,
|
||||||
SIDEBAR_WIDTH_STEP,
|
|
||||||
get_visible_menus,
|
get_visible_menus,
|
||||||
normalize_preferences,
|
normalize_preferences,
|
||||||
normalize_sidebar_width,
|
normalize_sidebar_width,
|
||||||
@@ -43,7 +43,6 @@ class UtilitiesGlobalContent(PluginTemplateExtension):
|
|||||||
"width": normalize_sidebar_width(preference.sidebar_width if preference else SIDEBAR_WIDTH_DEFAULT),
|
"width": normalize_sidebar_width(preference.sidebar_width if preference else SIDEBAR_WIDTH_DEFAULT),
|
||||||
"width_min": SIDEBAR_WIDTH_MIN,
|
"width_min": SIDEBAR_WIDTH_MIN,
|
||||||
"width_max": SIDEBAR_WIDTH_MAX,
|
"width_max": SIDEBAR_WIDTH_MAX,
|
||||||
"width_step": SIDEBAR_WIDTH_STEP,
|
|
||||||
"layout_update_url": reverse("plugins:netbox_utilities:navigation_layout"),
|
"layout_update_url": reverse("plugins:netbox_utilities:navigation_layout"),
|
||||||
"csrf_token": get_token(request),
|
"csrf_token": get_token(request),
|
||||||
}
|
}
|
||||||
@@ -52,6 +51,7 @@ class UtilitiesGlobalContent(PluginTemplateExtension):
|
|||||||
{
|
{
|
||||||
"navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(),
|
"navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(),
|
||||||
"navigation_preferences": preference_data,
|
"navigation_preferences": preference_data,
|
||||||
|
"asset_version": __version__,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
<link rel="stylesheet" href="{% static 'netbox_utilities/netbox_utilities.css' %}">
|
<link rel="stylesheet" href="{% static 'netbox_utilities/netbox_utilities.css' %}?v={{ asset_version }}">
|
||||||
{% if navigation_enabled %}
|
{% if navigation_enabled %}
|
||||||
{{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }}
|
{{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }}
|
||||||
<script src="{% static 'netbox_utilities/navigation.js' %}" defer></script>
|
<script src="{% static 'netbox_utilities/navigation.js' %}?v={{ asset_version }}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -22,46 +22,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-secondary">
|
<p class="text-secondary">
|
||||||
Passe Breite und Darstellung an, verschiebe Menüs mit den Pfeilen und blende nicht benötigte Bereiche aus.
|
Verschiebe Menüs mit den Pfeilen und blende nicht benötigte Bereiche aus.
|
||||||
Berechtigungen von NetBox werden dadurch nicht verändert.
|
Berechtigungen von NetBox werden dadurch nicht verändert.
|
||||||
</p>
|
</p>
|
||||||
<form method="post" id="navigation-preferences-form">
|
<form method="post" id="navigation-preferences-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<fieldset class="border rounded p-3 mb-4">
|
|
||||||
<legend class="float-none w-auto px-2 fs-4 mb-2">Darstellung</legend>
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input
|
|
||||||
class="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
role="switch"
|
|
||||||
id="sidebar-collapsed"
|
|
||||||
name="sidebar_collapsed"
|
|
||||||
{% if sidebar_collapsed %}checked{% endif %}
|
|
||||||
>
|
|
||||||
<label class="form-check-label fw-medium" for="sidebar-collapsed">
|
|
||||||
Eingeklappt – nur Hauptmenü-Icons anzeigen
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label class="form-label fw-medium" for="sidebar-width">
|
|
||||||
Breite im ausgeklappten Zustand:
|
|
||||||
<output id="sidebar-width-output" for="sidebar-width">{{ sidebar_width }} px</output>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
class="form-range"
|
|
||||||
type="range"
|
|
||||||
id="sidebar-width"
|
|
||||||
name="sidebar_width"
|
|
||||||
min="{{ sidebar_width_min }}"
|
|
||||||
max="{{ sidebar_width_max }}"
|
|
||||||
step="{{ sidebar_width_step }}"
|
|
||||||
value="{{ sidebar_width }}"
|
|
||||||
>
|
|
||||||
<div class="d-flex justify-content-between text-secondary small">
|
|
||||||
<span>Kleiner</span>
|
|
||||||
<span>Standard: 288 px</span>
|
|
||||||
<span>Größer</span>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<div class="list-group mb-3" id="navigation-menu-editor">
|
<div class="list-group mb-3" id="navigation-menu-editor">
|
||||||
{% for menu in menus %}
|
{% for menu in menus %}
|
||||||
<div class="list-group-item d-flex align-items-center gap-3 navigation-menu-row">
|
<div class="list-group-item d-flex align-items-center gap-3 navigation-menu-row">
|
||||||
@@ -119,14 +84,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const width = document.getElementById('sidebar-width');
|
|
||||||
const output = document.getElementById('sidebar-width-output');
|
|
||||||
if (width && output) {
|
|
||||||
width.addEventListener('input', () => {
|
|
||||||
output.value = `${width.value} px`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ class NormalizePreferencesTest(SimpleTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class SidebarLayoutTest(SimpleTestCase):
|
class SidebarLayoutTest(SimpleTestCase):
|
||||||
def test_normalizes_width_to_supported_steps(self):
|
def test_clamps_width_without_discrete_steps(self):
|
||||||
self.assertEqual(normalize_sidebar_width(None), SIDEBAR_WIDTH_DEFAULT)
|
self.assertEqual(normalize_sidebar_width(None), SIDEBAR_WIDTH_DEFAULT)
|
||||||
self.assertEqual(normalize_sidebar_width(100), SIDEBAR_WIDTH_MIN)
|
self.assertEqual(normalize_sidebar_width(100), SIDEBAR_WIDTH_MIN)
|
||||||
self.assertEqual(normalize_sidebar_width(500), SIDEBAR_WIDTH_MAX)
|
self.assertEqual(normalize_sidebar_width(500), SIDEBAR_WIDTH_MAX)
|
||||||
self.assertEqual(normalize_sidebar_width(300), 312)
|
self.assertEqual(normalize_sidebar_width(300), 300)
|
||||||
|
|
||||||
def test_toggles_icon_only_mode(self):
|
def test_toggles_icon_only_mode(self):
|
||||||
collapsed, width = update_sidebar_layout(False, SIDEBAR_WIDTH_DEFAULT, "toggle")
|
collapsed, width = update_sidebar_layout(False, SIDEBAR_WIDTH_DEFAULT, "toggle")
|
||||||
@@ -41,12 +41,15 @@ class SidebarLayoutTest(SimpleTestCase):
|
|||||||
self.assertTrue(collapsed)
|
self.assertTrue(collapsed)
|
||||||
self.assertEqual(width, SIDEBAR_WIDTH_DEFAULT)
|
self.assertEqual(width, SIDEBAR_WIDTH_DEFAULT)
|
||||||
|
|
||||||
def test_resizes_within_limits(self):
|
def test_resizes_continuously_within_limits_and_expands(self):
|
||||||
self.assertEqual(update_sidebar_layout(False, SIDEBAR_WIDTH_MIN, "smaller")[1], SIDEBAR_WIDTH_MIN)
|
self.assertEqual(update_sidebar_layout(True, SIDEBAR_WIDTH_DEFAULT, "resize", 301), (False, 301))
|
||||||
self.assertEqual(update_sidebar_layout(False, SIDEBAR_WIDTH_MAX, "larger")[1], SIDEBAR_WIDTH_MAX)
|
self.assertEqual(
|
||||||
self.assertLess(
|
update_sidebar_layout(False, SIDEBAR_WIDTH_DEFAULT, "resize", 100),
|
||||||
update_sidebar_layout(False, SIDEBAR_WIDTH_DEFAULT, "smaller")[1],
|
(False, SIDEBAR_WIDTH_MIN),
|
||||||
SIDEBAR_WIDTH_DEFAULT,
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
update_sidebar_layout(False, SIDEBAR_WIDTH_DEFAULT, "resize", 500),
|
||||||
|
(False, SIDEBAR_WIDTH_MAX),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_rejects_unknown_action(self):
|
def test_rejects_unknown_action(self):
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ class NavigationLayoutViewTest(SimpleTestCase):
|
|||||||
self.assertTrue(preference.sidebar_collapsed)
|
self.assertTrue(preference.sidebar_collapsed)
|
||||||
preference.save.assert_called_once_with(update_fields=("sidebar_collapsed", "sidebar_width", "updated"))
|
preference.save.assert_called_once_with(update_fields=("sidebar_collapsed", "sidebar_width", "updated"))
|
||||||
|
|
||||||
|
@patch("netbox_utilities.views.navigation_customization_enabled", return_value=True)
|
||||||
|
@patch("netbox_utilities.views.NavigationPreference.objects.get_or_create")
|
||||||
|
def test_saves_continuously_resized_width(self, get_or_create, _feature_enabled):
|
||||||
|
preference = MagicMock(sidebar_collapsed=True, sidebar_width=288)
|
||||||
|
get_or_create.return_value = (preference, False)
|
||||||
|
request = self.factory.post(
|
||||||
|
"/plugins/utilities/navigation/layout/",
|
||||||
|
{"action": "resize", "width": "317"},
|
||||||
|
)
|
||||||
|
request.user = MagicMock()
|
||||||
|
|
||||||
|
response = NavigationLayoutView().post(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(json.loads(response.content), {"collapsed": False, "width": 317})
|
||||||
|
self.assertFalse(preference.sidebar_collapsed)
|
||||||
|
self.assertEqual(preference.sidebar_width, 317)
|
||||||
|
|
||||||
@patch("netbox_utilities.views.navigation_customization_enabled", return_value=True)
|
@patch("netbox_utilities.views.navigation_customization_enabled", return_value=True)
|
||||||
@patch("netbox_utilities.views.NavigationPreference.objects.get_or_create")
|
@patch("netbox_utilities.views.NavigationPreference.objects.get_or_create")
|
||||||
def test_rejects_unknown_layout_action(self, get_or_create, _feature_enabled):
|
def test_rejects_unknown_layout_action(self, get_or_create, _feature_enabled):
|
||||||
|
|||||||
@@ -20,12 +20,8 @@ from .models import NavigationPreference, UtilitiesSettings
|
|||||||
from .module_installation import BulkModuleInstallError, install_modules
|
from .module_installation import BulkModuleInstallError, install_modules
|
||||||
from .navigation_helpers import (
|
from .navigation_helpers import (
|
||||||
SIDEBAR_WIDTH_DEFAULT,
|
SIDEBAR_WIDTH_DEFAULT,
|
||||||
SIDEBAR_WIDTH_MAX,
|
|
||||||
SIDEBAR_WIDTH_MIN,
|
|
||||||
SIDEBAR_WIDTH_STEP,
|
|
||||||
get_visible_menus,
|
get_visible_menus,
|
||||||
normalize_preferences,
|
normalize_preferences,
|
||||||
normalize_sidebar_width,
|
|
||||||
update_sidebar_layout,
|
update_sidebar_layout,
|
||||||
)
|
)
|
||||||
from .runtime import (
|
from .runtime import (
|
||||||
@@ -137,8 +133,6 @@ class NavigationPreferencesView(LoginRequiredMixin, View):
|
|||||||
)
|
)
|
||||||
preference.menu_order = order
|
preference.menu_order = order
|
||||||
preference.hidden_menus = hidden
|
preference.hidden_menus = hidden
|
||||||
preference.sidebar_collapsed = request.POST.get("sidebar_collapsed") == "on"
|
|
||||||
preference.sidebar_width = normalize_sidebar_width(request.POST.get("sidebar_width"))
|
|
||||||
preference.save()
|
preference.save()
|
||||||
messages.success(request, "Die persönliche Navigation wurde gespeichert.")
|
messages.success(request, "Die persönliche Navigation wurde gespeichert.")
|
||||||
return redirect("plugins:netbox_utilities:navigation_preferences")
|
return redirect("plugins:netbox_utilities:navigation_preferences")
|
||||||
@@ -159,13 +153,6 @@ class NavigationPreferencesView(LoginRequiredMixin, View):
|
|||||||
{
|
{
|
||||||
"menus": menus,
|
"menus": menus,
|
||||||
"feature_enabled": navigation_customization_enabled(),
|
"feature_enabled": navigation_customization_enabled(),
|
||||||
"sidebar_collapsed": preference.sidebar_collapsed if preference else False,
|
|
||||||
"sidebar_width": normalize_sidebar_width(
|
|
||||||
preference.sidebar_width if preference else SIDEBAR_WIDTH_DEFAULT
|
|
||||||
),
|
|
||||||
"sidebar_width_min": SIDEBAR_WIDTH_MIN,
|
|
||||||
"sidebar_width_max": SIDEBAR_WIDTH_MAX,
|
|
||||||
"sidebar_width_step": SIDEBAR_WIDTH_STEP,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -183,6 +170,7 @@ class NavigationLayoutView(LoginRequiredMixin, View):
|
|||||||
preference.sidebar_collapsed,
|
preference.sidebar_collapsed,
|
||||||
preference.sidebar_width,
|
preference.sidebar_width,
|
||||||
request.POST.get("action"),
|
request.POST.get("action"),
|
||||||
|
request.POST.get("width"),
|
||||||
)
|
)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
return HttpResponseBadRequest(str(error))
|
return HttpResponseBadRequest(str(error))
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-utilities"
|
name = "netbox-utilities"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering for NetBox 4.6"
|
description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering for NetBox 4.6"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
Reference in New Issue
Block a user