fix: replace sidebar controls with resize handle

This commit is contained in:
2026-07-31 11:11:19 +02:00
parent 6b36184f9d
commit 5a2276aec9
12 changed files with 249 additions and 198 deletions
+8 -9
View File
@@ -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.
+3 -1
View File
@@ -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"
+4 -9
View File
@@ -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;
} }
} }
+2 -2
View File
@@ -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):
+1 -13
View File
@@ -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
View File
@@ -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"