From 6b36184f9d8ea2a7b16f046e6035a80700f2ac49 Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 31 Jul 2026 10:25:47 +0200 Subject: [PATCH] feat: add collapsible resizable navigation --- README.md | 18 +- netbox_utilities/__init__.py | 2 +- ...003_navigationpreference_sidebar_layout.py | 27 +++ netbox_utilities/models.py | 7 +- netbox_utilities/navigation_helpers.py | 27 +++ .../static/netbox_utilities/navigation.js | 190 ++++++++++++++++-- .../netbox_utilities/netbox_utilities.css | 112 +++++++++++ netbox_utilities/template_content.py | 27 ++- .../navigation_preferences.html | 70 +++++-- .../tests/test_navigation_helpers.py | 35 +++- .../tests/test_navigation_views.py | 37 ++++ netbox_utilities/urls.py | 1 + netbox_utilities/views.py | 47 ++++- pyproject.toml | 2 +- 14 files changed, 567 insertions(+), 35 deletions(-) create mode 100644 netbox_utilities/migrations/0003_navigationpreference_sidebar_layout.py create mode 100644 netbox_utilities/tests/test_navigation_views.py diff --git a/README.md b/README.md index c63e97e..4ab0051 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Release-Tag oder ein bestimmter Commit verwendet werden: ```bash /opt/netbox/venv/bin/pip install --upgrade --force-reinstall \ - "git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.4.0" + "git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.5.0" ``` Alternativ kann hinter dem `@` die vollständige Commit-ID stehen. @@ -198,7 +198,21 @@ NetBox-Objektberechtigungen des Benutzers eingeschränkt. ### Navigation personalisieren -Unter **Plugins > NetBox Utilities > Navigation personalisieren** sieht der Benutzer alle Menüs, für die er aktuell Berechtigungen besitzt. Die Pfeiltasten ändern die Reihenfolge; der Schalter blendet ein Menü aus. **Zurücksetzen** stellt die NetBox-Reihenfolge wieder her. +Unter **Plugins > NetBox Utilities > Navigation personalisieren** sieht der +Benutzer alle Menüs, für die er aktuell Berechtigungen besitzt. Die Pfeiltasten +ändern die Reihenfolge; der Schalter blendet ein Menü aus. + +Zusätzlich kann jeder Benutzer die Desktop-Navigation auf einen reinen +**Icon-Modus** einklappen und die Breite der ausgeklappten Navigation zwischen +216 und 408 Pixeln einstellen. Am unteren Rand der Navigation stehen dafür +direkte Schaltflächen für **schmaler**, **einklappen/ausklappen** und **breiter** +zur Verfügung. Änderungen über diese Schaltflächen werden sofort im +Benutzerprofil gespeichert. Im Icon-Modus öffnen sich die Menüinhalte als +seitliches Flyout; auf kleinen beziehungsweise mobilen Ansichten bleibt das +normale NetBox-Menü erhalten. + +**Zurücksetzen** stellt neben Reihenfolge und Sichtbarkeit auch die normale +ausgeklappte Breite von 288 Pixeln wieder her. Die Funktion kann installationsweit über `navigation_customization_enabled = False` in `PLUGINS_CONFIG` abgeschaltet werden. diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 7ceeaf9..2e715f6 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -5,7 +5,7 @@ class NetBoxUtilitiesConfig(PluginConfig): name = "netbox_utilities" verbose_name = "NetBox Utilities" description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering" - version = "0.4.0" + version = "0.5.0" author = "LKE" base_url = "utilities" min_version = "4.6.5" diff --git a/netbox_utilities/migrations/0003_navigationpreference_sidebar_layout.py b/netbox_utilities/migrations/0003_navigationpreference_sidebar_layout.py new file mode 100644 index 0000000..6f9ff56 --- /dev/null +++ b/netbox_utilities/migrations/0003_navigationpreference_sidebar_layout.py @@ -0,0 +1,27 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("netbox_utilities", "0002_utilitiessettings_tenant_required"), + ] + + operations = [ + migrations.AddField( + model_name="navigationpreference", + name="sidebar_collapsed", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="navigationpreference", + name="sidebar_width", + field=models.PositiveSmallIntegerField( + default=288, + validators=[ + django.core.validators.MinValueValidator(216), + django.core.validators.MaxValueValidator(408), + ], + ), + ), + ] diff --git a/netbox_utilities/models.py b/netbox_utilities/models.py index 13d5a6f..58221ad 100644 --- a/netbox_utilities/models.py +++ b/netbox_utilities/models.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.core.validators import MinValueValidator +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -11,6 +11,11 @@ class NavigationPreference(models.Model): ) menu_order = models.JSONField(default=list, blank=True) hidden_menus = models.JSONField(default=list, blank=True) + sidebar_collapsed = models.BooleanField(default=False) + sidebar_width = models.PositiveSmallIntegerField( + default=288, + validators=[MinValueValidator(216), MaxValueValidator(408)], + ) updated = models.DateTimeField(auto_now=True) class Meta: diff --git a/netbox_utilities/navigation_helpers.py b/netbox_utilities/navigation_helpers.py index 9eebdea..39e5832 100644 --- a/netbox_utilities/navigation_helpers.py +++ b/netbox_utilities/navigation_helpers.py @@ -1,5 +1,32 @@ from netbox.navigation.menu import get_menus +SIDEBAR_WIDTH_MIN = 216 +SIDEBAR_WIDTH_MAX = 408 +SIDEBAR_WIDTH_STEP = 24 +SIDEBAR_WIDTH_DEFAULT = 288 + + +def normalize_sidebar_width(value): + try: + width = int(value) + except (TypeError, ValueError): + return SIDEBAR_WIDTH_DEFAULT + + width = 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): + width = normalize_sidebar_width(width) + if action == "toggle": + return not collapsed, width + if action == "smaller": + return collapsed, max(SIDEBAR_WIDTH_MIN, width - SIDEBAR_WIDTH_STEP) + if action == "larger": + return collapsed, min(SIDEBAR_WIDTH_MAX, width + SIDEBAR_WIDTH_STEP) + raise ValueError("Unbekannte Navigationsaktion.") + def _permitted_menu_items(menu, user): for group in menu.groups: diff --git a/netbox_utilities/static/netbox_utilities/navigation.js b/netbox_utilities/static/netbox_utilities/navigation.js index 94b82f9..f582171 100644 --- a/netbox_utilities/static/netbox_utilities/navigation.js +++ b/netbox_utilities/static/netbox_utilities/navigation.js @@ -1,18 +1,36 @@ (() => { 'use strict'; - function applyNavigationPreferences() { - const dataElement = document.getElementById('netbox-utilities-navigation-data'); - const sidebar = document.querySelector('#sidebar-menu > ul.navbar-nav'); - if (!dataElement || !sidebar) return; + const dataElement = document.getElementById('netbox-utilities-navigation-data'); + if (!dataElement) return; - let preferences; - try { - preferences = JSON.parse(dataElement.textContent); - } catch (_error) { - return; - } + let preferences; + try { + preferences = JSON.parse(dataElement.textContent); + } catch (_error) { + return; + } + const root = document.documentElement; + + function normalizeWidth(value) { + const minimum = Number(preferences.width_min) || 216; + const maximum = Number(preferences.width_max) || 408; + const step = Number(preferences.width_step) || 24; + const requested = Number(value) || 288; + const snapped = minimum + Math.round((requested - minimum) / step) * step; + return Math.min(maximum, Math.max(minimum, snapped)); + } + + function applyRootLayout() { + preferences.width = normalizeWidth(preferences.width); + preferences.collapsed = Boolean(preferences.collapsed); + root.classList.add('netbox-utilities-navigation-layout'); + root.classList.toggle('netbox-utilities-navigation-collapsed', preferences.collapsed); + root.style.setProperty('--netbox-utilities-sidebar-width', `${preferences.width}px`); + } + + function applyMenuPreferences(sidebar) { const descriptorByPath = new Map( preferences.menus.map((menu) => [new URL(menu.first_url, document.baseURI).pathname, menu]) ); @@ -36,9 +54,157 @@ }); } + function updateMenuTitles(sidebar) { + sidebar.querySelectorAll(':scope > li.nav-item.dropdown > button.nav-link').forEach((button) => { + if (preferences.collapsed) { + button.dataset.netboxUtilitiesTitle = 'true'; + button.title = button.getAttribute('aria-label') || ''; + const menu = button.closest('.nav-item.dropdown')?.querySelector(':scope > .dropdown-menu.show'); + if (menu) { + const top = Math.max(8, button.getBoundingClientRect().top); + menu.style.setProperty('--netbox-utilities-dropdown-top', `${top}px`); + } + } else if (button.dataset.netboxUtilitiesTitle) { + button.removeAttribute('title'); + delete button.dataset.netboxUtilitiesTitle; + } + }); + } + + function configureCollapsedDropdowns(sidebar) { + sidebar.addEventListener('show.bs.dropdown', (event) => { + if (!preferences.collapsed) return; + const item = event.target.closest('.nav-item.dropdown'); + if (!item) return; + + sidebar.querySelectorAll(':scope > li.nav-item.dropdown').forEach((otherItem) => { + if (otherItem === item) return; + const otherButton = otherItem.querySelector(':scope > button.nav-link'); + const otherMenu = otherItem.querySelector(':scope > .dropdown-menu'); + otherButton?.classList.remove('show'); + otherButton?.setAttribute('aria-expanded', 'false'); + otherMenu?.classList.remove('show'); + }); + + const button = item.querySelector(':scope > button.nav-link'); + const menu = item.querySelector(':scope > .dropdown-menu'); + if (!button || !menu) return; + const top = Math.max(8, button.getBoundingClientRect().top); + menu.style.setProperty('--netbox-utilities-dropdown-top', `${top}px`); + }); + } + + function createControl(action, icon, label) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'btn btn-sm btn-ghost-secondary netbox-utilities-sidebar-control'; + button.dataset.action = action; + button.title = label; + button.setAttribute('aria-label', label); + const iconElement = document.createElement('i'); + iconElement.className = `mdi ${icon}`; + iconElement.setAttribute('aria-hidden', 'true'); + button.appendChild(iconElement); + return button; + } + + function updateControls(controls, sidebar, pending = false) { + 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; + + const controls = document.createElement('div'); + controls.className = 'netbox-utilities-sidebar-controls'; + controls.setAttribute('role', 'group'); + controls.setAttribute('aria-label', 'Navigationsgröße'); + controls.append( + createControl('smaller', 'mdi-minus', 'Navigation schmaler'), + createControl('toggle', 'mdi-arrow-collapse-left', 'Navigation auf Icons einklappen'), + createControl('larger', 'mdi-plus', 'Navigation breiter') + ); + + const releaseInfo = sidebarMenu.querySelector(':scope > .text-muted'); + sidebarMenu.insertBefore(controls, releaseInfo || null); + updateControls(controls, sidebar); + + controls.addEventListener('click', async (event) => { + const button = event.target.closest('button[data-action]'); + if (!button || button.disabled) return; + + const previous = {collapsed: preferences.collapsed, width: preferences.width}; + applyAction(button.dataset.action); + updateControls(controls, sidebar, true); + + try { + const response = await fetch(preferences.layout_update_url, { + 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.width = normalizeWidth(saved.width); + controls.classList.remove('text-danger'); + controls.removeAttribute('title'); + } catch (error) { + preferences.collapsed = previous.collapsed; + preferences.width = previous.width; + controls.classList.add('text-danger'); + controls.title = 'Die Navigationseinstellung konnte nicht gespeichert werden.'; + console.error('Could not save NetBox navigation layout', error); + } + + applyRootLayout(); + updateControls(controls, sidebar); + }); + } + + function initializeNavigation() { + const sidebarMenu = document.getElementById('sidebar-menu'); + const sidebar = document.querySelector('#sidebar-menu > ul.navbar-nav'); + if (!sidebarMenu || !sidebar) return; + + applyMenuPreferences(sidebar); + updateMenuTitles(sidebar); + configureCollapsedDropdowns(sidebar); + addLayoutControls(sidebarMenu, sidebar); + } + + applyRootLayout(); if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', applyNavigationPreferences, {once: true}); + document.addEventListener('DOMContentLoaded', initializeNavigation, {once: true}); } else { - applyNavigationPreferences(); + initializeNavigation(); } })(); diff --git a/netbox_utilities/static/netbox_utilities/netbox_utilities.css b/netbox_utilities/static/netbox_utilities/netbox_utilities.css index 471116e..f410f21 100644 --- a/netbox_utilities/static/netbox_utilities/netbox_utilities.css +++ b/netbox_utilities/static/netbox_utilities/netbox_utilities.css @@ -9,3 +9,115 @@ vertical-align: bottom; white-space: nowrap; } + +.netbox-utilities-sidebar-controls { + display: none; +} + +@media (min-width: 992px) { + html.netbox-utilities-navigation-layout { + --netbox-utilities-sidebar-effective-width: var(--netbox-utilities-sidebar-width, 18rem); + } + + html.netbox-utilities-navigation-collapsed { + --netbox-utilities-sidebar-effective-width: 4.5rem; + } + + html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg { + width: var(--netbox-utilities-sidebar-effective-width); + transition: width 160ms ease; + } + + 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 { + margin-left: var(--netbox-utilities-sidebar-effective-width); + transition: margin-left 160ms ease; + } + + html.netbox-utilities-navigation-layout .navbar-vertical.navbar-expand-lg img.motif { + width: var(--netbox-utilities-sidebar-effective-width); + } + + .netbox-utilities-sidebar-controls { + position: sticky; + bottom: 0.5rem; + z-index: 2; + display: flex; + justify-content: center; + gap: 0.25rem; + margin: 0.75rem; + padding: 0.35rem; + border: 1px solid var(--tblr-border-color-translucent); + border-radius: var(--tblr-border-radius); + background: var(--tblr-bg-surface); + box-shadow: var(--tblr-box-shadow-sm); + } + + .netbox-utilities-sidebar-control { + flex: 0 1 3rem; + padding-inline: 0.5rem; + } + + html.netbox-utilities-navigation-collapsed .navbar-vertical.navbar-expand-lg > .container-fluid { + padding-inline: 0.35rem; + } + + html.netbox-utilities-navigation-collapsed .navbar-vertical.navbar-expand-lg .navbar-brand, + html.netbox-utilities-navigation-collapsed .navbar-vertical.navbar-expand-lg img.motif, + html.netbox-utilities-navigation-collapsed #sidebar-menu > .text-muted, + html.netbox-utilities-navigation-collapsed #sidebar-menu .nav-link-title, + html.netbox-utilities-navigation-collapsed #sidebar-menu .dropdown-toggle::after { + display: none !important; + } + + html.netbox-utilities-navigation-collapsed #sidebar-menu, + html.netbox-utilities-navigation-collapsed #sidebar-menu > .navbar-nav { + width: 100%; + } + + html.netbox-utilities-navigation-collapsed #sidebar-menu .nav-item.dropdown > button.nav-link { + justify-content: center; + min-height: 2.75rem; + padding-inline: 0; + text-align: center; + } + + html.netbox-utilities-navigation-collapsed #sidebar-menu .nav-link-icon { + width: auto; + margin: 0; + font-size: 1.5rem; + } + + html.netbox-utilities-navigation-collapsed #sidebar-menu .nav-item.dropdown > .dropdown-menu { + position: fixed !important; + top: var(--netbox-utilities-dropdown-top, 0.5rem) !important; + right: auto !important; + bottom: auto !important; + left: var(--netbox-utilities-sidebar-effective-width) !important; + z-index: 1040; + width: min(22rem, calc(100vw - var(--netbox-utilities-sidebar-effective-width) - 1rem)); + max-height: calc(100vh - var(--netbox-utilities-dropdown-top, 0.5rem) - 0.5rem); + overflow-y: auto; + transform: none !important; + border: 1px solid var(--tblr-border-color); + border-radius: var(--tblr-border-radius); + background: var(--tblr-bg-surface); + box-shadow: var(--tblr-box-shadow-lg); + } + + html.netbox-utilities-navigation-collapsed .netbox-utilities-sidebar-controls { + margin-inline: 0; + } + + html.netbox-utilities-navigation-collapsed .netbox-utilities-sidebar-control:not([data-action="toggle"]) { + display: none; + } +} + +@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 ~ .navbar, + html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .page-wrapper { + transition: none; + } +} diff --git a/netbox_utilities/template_content.py b/netbox_utilities/template_content.py index 2d59a61..7365f5d 100644 --- a/netbox_utilities/template_content.py +++ b/netbox_utilities/template_content.py @@ -1,16 +1,32 @@ from dcim.models import ModuleBay +from django.middleware.csrf import get_token +from django.urls import reverse from netbox.plugins import PluginTemplateExtension from tenancy.models import Tenant, TenantGroup from .models import NavigationPreference -from .navigation_helpers import get_visible_menus, normalize_preferences +from .navigation_helpers import ( + SIDEBAR_WIDTH_DEFAULT, + SIDEBAR_WIDTH_MAX, + SIDEBAR_WIDTH_MIN, + SIDEBAR_WIDTH_STEP, + get_visible_menus, + normalize_preferences, + normalize_sidebar_width, +) from .runtime import navigation_customization_enabled, tenant_filter_enabled class UtilitiesGlobalContent(PluginTemplateExtension): def head(self): request = self.context["request"] - preference_data = {"order": [], "hidden": [], "menus": []} + preference_data = { + "order": [], + "hidden": [], + "menus": [], + "collapsed": False, + "width": SIDEBAR_WIDTH_DEFAULT, + } if request.user.is_authenticated and navigation_customization_enabled(): descriptors = get_visible_menus(request.user) preference = NavigationPreference.objects.filter(user=request.user).first() @@ -23,6 +39,13 @@ class UtilitiesGlobalContent(PluginTemplateExtension): "order": order, "hidden": hidden, "menus": descriptors, + "collapsed": preference.sidebar_collapsed if preference else False, + "width": normalize_sidebar_width(preference.sidebar_width if preference else SIDEBAR_WIDTH_DEFAULT), + "width_min": SIDEBAR_WIDTH_MIN, + "width_max": SIDEBAR_WIDTH_MAX, + "width_step": SIDEBAR_WIDTH_STEP, + "layout_update_url": reverse("plugins:netbox_utilities:navigation_layout"), + "csrf_token": get_token(request), } return self.render( "netbox_utilities/head.html", diff --git a/netbox_utilities/templates/netbox_utilities/navigation_preferences.html b/netbox_utilities/templates/netbox_utilities/navigation_preferences.html index f97e2eb..75d9f15 100644 --- a/netbox_utilities/templates/netbox_utilities/navigation_preferences.html +++ b/netbox_utilities/templates/netbox_utilities/navigation_preferences.html @@ -22,11 +22,46 @@ {% else %}

- Verschiebe Menüs mit den Pfeilen und blende nicht benötigte Bereiche aus. + Passe Breite und Darstellung an, verschiebe Menüs mit den Pfeilen und blende nicht benötigte Bereiche aus. Berechtigungen von NetBox werden dadurch nicht verändert.