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
|
||||
/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.
|
||||
@@ -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
|
||||
ä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.
|
||||
Zusätzlich kann jeder Benutzer die Desktop-Navigation direkt an der rechten
|
||||
Kante zwischen 216 und 408 Pixeln breiter oder schmaler ziehen. Ein kleiner
|
||||
Pfeilgriff in der Mitte dieser Kante klappt sie auf einen reinen **Icon-Modus**
|
||||
ein und wieder aus. Dafür gibt es kein zusätzliches Bedienmenü. Beim Ziehen
|
||||
wird die neue Breite nach dem Loslassen 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.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from netbox.plugins import PluginConfig, get_plugin_config
|
||||
|
||||
__version__ = "0.5.1"
|
||||
|
||||
|
||||
class NetBoxUtilitiesConfig(PluginConfig):
|
||||
name = "netbox_utilities"
|
||||
verbose_name = "NetBox Utilities"
|
||||
description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering"
|
||||
version = "0.5.0"
|
||||
version = __version__
|
||||
author = "LKE"
|
||||
base_url = "utilities"
|
||||
min_version = "4.6.5"
|
||||
|
||||
@@ -2,7 +2,6 @@ from netbox.navigation.menu import get_menus
|
||||
|
||||
SIDEBAR_WIDTH_MIN = 216
|
||||
SIDEBAR_WIDTH_MAX = 408
|
||||
SIDEBAR_WIDTH_STEP = 24
|
||||
SIDEBAR_WIDTH_DEFAULT = 288
|
||||
|
||||
|
||||
@@ -12,19 +11,15 @@ def normalize_sidebar_width(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
|
||||
return min(SIDEBAR_WIDTH_MAX, max(SIDEBAR_WIDTH_MIN, width))
|
||||
|
||||
|
||||
def update_sidebar_layout(collapsed, width, action):
|
||||
def update_sidebar_layout(collapsed, width, action, requested_width=None):
|
||||
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)
|
||||
if action == "resize":
|
||||
return False, normalize_sidebar_width(requested_width)
|
||||
raise ValueError("Unbekannte Navigationsaktion.")
|
||||
|
||||
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
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));
|
||||
const requested = Number(value);
|
||||
const width = Number.isFinite(requested) ? Math.round(requested) : 288;
|
||||
return Math.min(maximum, Math.max(minimum, width));
|
||||
}
|
||||
|
||||
function applyRootLayout() {
|
||||
@@ -58,7 +57,8 @@
|
||||
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') || '';
|
||||
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');
|
||||
if (menu) {
|
||||
const top = Math.max(8, button.getBoundingClientRect().top);
|
||||
@@ -94,111 +94,149 @@
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
async function saveLayout(action, width) {
|
||||
const body = new URLSearchParams({action});
|
||||
if (width !== undefined) body.set('width', String(width));
|
||||
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,
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
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) {
|
||||
function addResizeHandle(sidebarElement, 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 handle = document.createElement('div');
|
||||
handle.className = 'netbox-utilities-sidebar-resizer';
|
||||
handle.tabIndex = 0;
|
||||
handle.setAttribute('role', 'separator');
|
||||
handle.setAttribute('aria-orientation', 'vertical');
|
||||
handle.setAttribute('aria-label', 'Breite der Navigation ändern');
|
||||
|
||||
const releaseInfo = sidebarMenu.querySelector(':scope > .text-muted');
|
||||
sidebarMenu.insertBefore(controls, releaseInfo || null);
|
||||
updateControls(controls, sidebar);
|
||||
const toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
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) => {
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (!button || button.disabled) return;
|
||||
let resizing = false;
|
||||
let previousLayout = null;
|
||||
|
||||
const previous = {collapsed: preferences.collapsed, width: preferences.width};
|
||||
applyAction(button.dataset.action);
|
||||
updateControls(controls, sidebar, true);
|
||||
function updateHandle() {
|
||||
const minimum = Number(preferences.width_min) || 216;
|
||||
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 {
|
||||
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();
|
||||
const saved = await saveLayout(action, width);
|
||||
preferences.collapsed = Boolean(saved.collapsed);
|
||||
preferences.width = normalizeWidth(saved.width);
|
||||
controls.classList.remove('text-danger');
|
||||
controls.removeAttribute('title');
|
||||
handle.classList.remove('netbox-utilities-sidebar-save-error');
|
||||
handle.removeAttribute('data-save-error');
|
||||
} catch (error) {
|
||||
preferences.collapsed = previous.collapsed;
|
||||
preferences.width = previous.width;
|
||||
controls.classList.add('text-danger');
|
||||
controls.title = 'Die Navigationseinstellung konnte nicht gespeichert werden.';
|
||||
preferences.collapsed = fallback.collapsed;
|
||||
preferences.width = fallback.width;
|
||||
handle.classList.add('netbox-utilities-sidebar-save-error');
|
||||
handle.dataset.saveError = 'Die Navigationseinstellung konnte nicht gespeichert werden.';
|
||||
console.error('Could not save NetBox navigation layout', error);
|
||||
}
|
||||
|
||||
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() {
|
||||
const sidebarMenu = document.getElementById('sidebar-menu');
|
||||
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);
|
||||
updateMenuTitles(sidebar);
|
||||
configureCollapsedDropdowns(sidebar);
|
||||
addLayoutControls(sidebarMenu, sidebar);
|
||||
addResizeHandle(sidebarElement, sidebar);
|
||||
}
|
||||
|
||||
applyRootLayout();
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.netbox-utilities-sidebar-controls {
|
||||
.netbox-utilities-sidebar-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -38,24 +38,77 @@
|
||||
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-resizer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(var(--netbox-utilities-sidebar-effective-width) - 5px);
|
||||
z-index: 1045;
|
||||
display: block;
|
||||
width: 10px;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
outline: 0;
|
||||
transition: left 160ms ease;
|
||||
}
|
||||
|
||||
.netbox-utilities-sidebar-control {
|
||||
flex: 0 1 3rem;
|
||||
padding-inline: 0.5rem;
|
||||
.netbox-utilities-sidebar-resizer::before {
|
||||
position: absolute;
|
||||
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 {
|
||||
@@ -105,19 +158,17 @@
|
||||
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;
|
||||
html.netbox-utilities-navigation-collapsed .netbox-utilities-sidebar-resizer {
|
||||
cursor: e-resize;
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ from django.urls import reverse
|
||||
from netbox.plugins import PluginTemplateExtension
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
|
||||
from . import __version__
|
||||
from .models import NavigationPreference
|
||||
from .navigation_helpers import (
|
||||
SIDEBAR_WIDTH_DEFAULT,
|
||||
SIDEBAR_WIDTH_MAX,
|
||||
SIDEBAR_WIDTH_MIN,
|
||||
SIDEBAR_WIDTH_STEP,
|
||||
get_visible_menus,
|
||||
normalize_preferences,
|
||||
normalize_sidebar_width,
|
||||
@@ -43,7 +43,6 @@ class UtilitiesGlobalContent(PluginTemplateExtension):
|
||||
"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),
|
||||
}
|
||||
@@ -52,6 +51,7 @@ class UtilitiesGlobalContent(PluginTemplateExtension):
|
||||
{
|
||||
"navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(),
|
||||
"navigation_preferences": preference_data,
|
||||
"asset_version": __version__,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% 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 %}
|
||||
{{ 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 %}
|
||||
|
||||
@@ -22,46 +22,11 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<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.
|
||||
</p>
|
||||
<form method="post" id="navigation-preferences-form">
|
||||
{% 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">
|
||||
{% for menu in menus %}
|
||||
<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>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -29,11 +29,11 @@ class NormalizePreferencesTest(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(100), SIDEBAR_WIDTH_MIN)
|
||||
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):
|
||||
collapsed, width = update_sidebar_layout(False, SIDEBAR_WIDTH_DEFAULT, "toggle")
|
||||
@@ -41,12 +41,15 @@ class SidebarLayoutTest(SimpleTestCase):
|
||||
self.assertTrue(collapsed)
|
||||
self.assertEqual(width, SIDEBAR_WIDTH_DEFAULT)
|
||||
|
||||
def test_resizes_within_limits(self):
|
||||
self.assertEqual(update_sidebar_layout(False, SIDEBAR_WIDTH_MIN, "smaller")[1], SIDEBAR_WIDTH_MIN)
|
||||
self.assertEqual(update_sidebar_layout(False, SIDEBAR_WIDTH_MAX, "larger")[1], SIDEBAR_WIDTH_MAX)
|
||||
self.assertLess(
|
||||
update_sidebar_layout(False, SIDEBAR_WIDTH_DEFAULT, "smaller")[1],
|
||||
SIDEBAR_WIDTH_DEFAULT,
|
||||
def test_resizes_continuously_within_limits_and_expands(self):
|
||||
self.assertEqual(update_sidebar_layout(True, SIDEBAR_WIDTH_DEFAULT, "resize", 301), (False, 301))
|
||||
self.assertEqual(
|
||||
update_sidebar_layout(False, SIDEBAR_WIDTH_DEFAULT, "resize", 100),
|
||||
(False, SIDEBAR_WIDTH_MIN),
|
||||
)
|
||||
self.assertEqual(
|
||||
update_sidebar_layout(False, SIDEBAR_WIDTH_DEFAULT, "resize", 500),
|
||||
(False, SIDEBAR_WIDTH_MAX),
|
||||
)
|
||||
|
||||
def test_rejects_unknown_action(self):
|
||||
|
||||
@@ -25,6 +25,24 @@ class NavigationLayoutViewTest(SimpleTestCase):
|
||||
self.assertTrue(preference.sidebar_collapsed)
|
||||
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.NavigationPreference.objects.get_or_create")
|
||||
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 .navigation_helpers import (
|
||||
SIDEBAR_WIDTH_DEFAULT,
|
||||
SIDEBAR_WIDTH_MAX,
|
||||
SIDEBAR_WIDTH_MIN,
|
||||
SIDEBAR_WIDTH_STEP,
|
||||
get_visible_menus,
|
||||
normalize_preferences,
|
||||
normalize_sidebar_width,
|
||||
update_sidebar_layout,
|
||||
)
|
||||
from .runtime import (
|
||||
@@ -137,8 +133,6 @@ class NavigationPreferencesView(LoginRequiredMixin, View):
|
||||
)
|
||||
preference.menu_order = order
|
||||
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()
|
||||
messages.success(request, "Die persönliche Navigation wurde gespeichert.")
|
||||
return redirect("plugins:netbox_utilities:navigation_preferences")
|
||||
@@ -159,13 +153,6 @@ class NavigationPreferencesView(LoginRequiredMixin, View):
|
||||
{
|
||||
"menus": menus,
|
||||
"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_width,
|
||||
request.POST.get("action"),
|
||||
request.POST.get("width"),
|
||||
)
|
||||
except ValueError as error:
|
||||
return HttpResponseBadRequest(str(error))
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
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"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
Reference in New Issue
Block a user