Merge feature

This commit is contained in:
jeremystretch 2021-07-30 15:13:55 -04:00
commit 3ba122afd4
32 changed files with 2322 additions and 261 deletions

View File

@ -5,6 +5,8 @@
### Bug Fixes
* [#6811](https://github.com/netbox-community/netbox/issues/6811) - Fix exception when editing users
* [#6827](https://github.com/netbox-community/netbox/issues/6827) - Fix circuit termination connection dropdown
* [#6846](https://github.com/netbox-community/netbox/issues/6846) - Form-driven REST API calls should use brief mode
---

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -41,6 +41,32 @@
"eslint-plugin-prettier": "^3.3.1",
"prettier": "^2.2.1",
"prettier-eslint": "^12.0.0",
"stylelint": "^13.13.1",
"stylelint-config-twbs-bootstrap": "^2.2.3",
"typescript": "^4.2.3"
},
"stylelint": {
"extends": "stylelint-config-twbs-bootstrap/scss",
"rules": {
"selector-max-class": 16,
"selector-max-compound-selectors": 16,
"selector-no-qualifying-type": [
true,
{
"ignore": [
"attribute",
"class"
]
}
],
"number-leading-zero": "always",
"string-quotes": "single",
"selector-pseudo-element-colon-notation": "single",
"declaration-property-value-disallowed-list": {
"border": "none",
"outline": "none"
},
"scss/selector-no-union-class-name": true
}
}
}

View File

@ -32,9 +32,7 @@ function handleSearchDropdownClick(event: Event, button: HTMLButtonElement) {
* Initialize Search Bar Elements.
*/
function initSearchBar() {
for (const dropdown of getElements<HTMLUListElement>(
'div.search-container ul.search-obj-selector',
)) {
for (const dropdown of getElements<HTMLUListElement>('.search-obj-selector')) {
for (const button of dropdown.querySelectorAll<HTMLButtonElement>(
'li > button.dropdown-item',
)) {

View File

@ -123,6 +123,11 @@ class APISelect {
*/
private disabledOptions: Array<string> = [];
/**
* Array of properties which if truthy on an API object should be considered disabled.
*/
private disabledAttributes: Array<string> = DISABLED_ATTRIBUTES;
constructor(base: HTMLSelectElement) {
// Initialize readonly properties.
this.base = base;
@ -141,6 +146,7 @@ class APISelect {
this.loadEvent = new Event(`netbox.select.onload.${base.name}`);
this.placeholder = this.getPlaceholder();
this.disabledOptions = this.getDisabledOptions();
this.disabledAttributes = this.getDisabledAttributes();
this.slim = new SlimSelect({
select: this.base,
@ -158,12 +164,18 @@ class APISelect {
this.updateQueryParams(filter);
}
// Add any already-resolved key/value pairs to the API query parameters.
for (const [key, value] of this.filterParams.entries()) {
if (isTruthy(value)) {
this.queryParams.set(key, value);
}
}
for (const filter of this.pathValues.keys()) {
this.updatePathValues(filter);
}
// TODO: Re-enable this. Disabled because `_depth` field is missing from brief responses.
// this.queryParams.set('brief', true);
this.queryParams.set('brief', true);
this.queryParams.set('limit', 0);
this.updateQueryUrl();
@ -323,7 +335,7 @@ class APISelect {
if (!this.preSorted) {
this.preSorted = true;
}
text = `<span class="depth">${'─'.repeat(result._depth)}</span> ${text}`;
text = `<span class="depth">${'─'.repeat(result._depth)}&nbsp;</span>${text}`;
}
const data = {} as Record<string, string>;
const value = result.id.toString();
@ -336,7 +348,7 @@ class APISelect {
data[key] = String(v);
}
// Set option to disabled if the result contains a matching key and is truthy.
if (DISABLED_ATTRIBUTES.some(key => key.toLowerCase() === k.toLowerCase())) {
if (this.disabledAttributes.some(key => key.toLowerCase() === k.toLowerCase())) {
if (typeof v === 'string' && v.toLowerCase() !== 'false') {
disabled = true;
} else if (typeof v === 'boolean' && v === true) {
@ -547,6 +559,19 @@ class APISelect {
return disabledOptions;
}
/**
* Get this element's disabled attribute keys. For example, if `disabled-indicator` is set to
* `'_occupied'` and an API object contains `{ _occupied: true }`, the option will be disabled.
*/
private getDisabledAttributes(): string[] {
let disabled = [...DISABLED_ATTRIBUTES] as string[];
const attr = this.base.getAttribute('disabled-indicator');
if (isTruthy(attr)) {
disabled = [...disabled, attr];
}
return disabled;
}
/**
* Parse the `data-url` attribute to add any Django template variables to `pathValues` as keys
* with empty values. As those keys' corresponding form fields' values change, `pathValues` will

View File

@ -1,22 +1,246 @@
import { getElement, getElements } from './util';
import { StateManager } from './state';
import { getElements, isElement } from './util';
const breakpoints = {
sm: 540,
md: 720,
lg: 960,
xl: 1140,
};
type NavState = { pinned: boolean };
type BodyAttr = 'show' | 'hide' | 'hidden' | 'pinned';
function toggleBodyPosition(position: HTMLBodyElement['style']['position']): void {
for (const element of getElements('body')) {
element.style.position = position;
class SideNav {
/**
* Sidenav container element.
*/
private base: HTMLDivElement;
/**
* SideNav internal state manager.
*/
private state: StateManager<NavState>;
constructor(base: HTMLDivElement) {
this.base = base;
this.state = new StateManager<NavState>({ pinned: true }, { persist: true });
this.init();
this.initLinks();
}
/**
* Determine if `document.body` has a sidenav attribute.
*/
private bodyHas(attr: BodyAttr): boolean {
return document.body.hasAttribute(`data-sidenav-${attr}`);
}
/**
* Remove sidenav attributes from `document.body`.
*/
private bodyRemove(...attrs: BodyAttr[]): void {
for (const attr of attrs) {
document.body.removeAttribute(`data-sidenav-${attr}`);
}
}
/**
* Add sidenav attributes to `document.body`.
*/
private bodyAdd(...attrs: BodyAttr[]): void {
for (const attr of attrs) {
document.body.setAttribute(`data-sidenav-${attr}`, '');
}
}
/**
* Set initial values & add event listeners.
*/
private init() {
for (const toggler of this.base.querySelectorAll('.sidenav-toggle')) {
toggler.addEventListener('click', event => this.onToggle(event));
}
for (const toggler of getElements<HTMLButtonElement>('.sidenav-toggle-mobile')) {
toggler.addEventListener('click', event => this.onMobileToggle(event));
}
if (window.innerWidth > 1200) {
if (this.state.get('pinned')) {
this.pin();
}
if (!this.state.get('pinned')) {
this.unpin();
}
window.addEventListener('resize', () => this.onResize());
}
if (window.innerWidth < 1200) {
this.bodyRemove('hide');
this.bodyAdd('hidden');
window.addEventListener('resize', () => this.onResize());
}
this.base.addEventListener('mouseenter', () => this.onEnter());
this.base.addEventListener('mouseleave', () => this.onLeave());
}
/**
* If the sidenav is shown, expand active nav links. Otherwise, collapse them.
*/
private initLinks(): void {
for (const link of this.getActiveLinks()) {
if (this.bodyHas('show')) {
this.activateLink(link, 'expand');
} else if (this.bodyHas('hidden')) {
this.activateLink(link, 'collapse');
}
}
}
private show(): void {
this.bodyAdd('show');
this.bodyRemove('hidden', 'hide');
}
private hide(): void {
this.bodyAdd('hidden');
this.bodyRemove('pinned', 'show');
for (const collapse of this.base.querySelectorAll('.collapse')) {
collapse.classList.remove('show');
}
}
/**
* Pin the sidenav.
*/
private pin(): void {
this.bodyAdd('show', 'pinned');
this.bodyRemove('hidden');
this.state.set('pinned', true);
}
/**
* Unpin the sidenav.
*/
private unpin(): void {
this.bodyRemove('pinned', 'show');
this.bodyAdd('hidden');
for (const collapse of this.base.querySelectorAll('.collapse')) {
collapse.classList.remove('show');
}
this.state.set('pinned', false);
}
/**
* Starting from the bottom-most active link in the element tree, work backwards to determine the
* link's containing `.collapse` element and the `.collapse` element's containing `.nav-link`
* element. Once found, expand (or collapse) the `.collapse` element and add (or remove) the
* `.active` class to the the parent `.nav-link` element.
*
* @param link Active nav link
* @param action Expand or Collapse
*/
private activateLink(link: HTMLAnchorElement, action: 'expand' | 'collapse'): void {
// Find the closest .collapse element, which should contain `link`.
const collapse = link.closest('.collapse') as Nullable<HTMLDivElement>;
if (isElement(collapse)) {
// Find the closest `.nav-link`, which should be adjacent to the `.collapse` element.
const groupLink = collapse.parentElement?.querySelector('.nav-link');
if (isElement(groupLink)) {
groupLink.classList.add('active');
switch (action) {
case 'expand':
groupLink.setAttribute('aria-expanded', 'true');
collapse.classList.add('show');
link.classList.add('active');
break;
case 'collapse':
groupLink.setAttribute('aria-expanded', 'false');
collapse.classList.remove('show');
link.classList.remove('active');
break;
}
}
}
}
/**
* Find any nav links with `href` attributes matching the current path, to determine which nav
* link should be considered active.
*/
private *getActiveLinks(): Generator<HTMLAnchorElement> {
for (const link of this.base.querySelectorAll<HTMLAnchorElement>(
'.navbar-nav .nav .nav-item a.nav-link',
)) {
const href = new RegExp(link.href, 'gi');
if (Boolean(window.location.href.match(href))) {
yield link;
}
}
}
/**
* Show the sidenav and expand any active sections.
*/
private onEnter(): void {
if (!this.bodyHas('pinned')) {
this.bodyRemove('hide', 'hidden');
this.bodyAdd('show');
for (const link of this.getActiveLinks()) {
this.activateLink(link, 'expand');
}
}
}
/**
* Hide the sidenav and collapse any active sections.
*/
private onLeave(): void {
if (!this.bodyHas('pinned')) {
this.bodyRemove('show');
this.bodyAdd('hide');
for (const link of this.getActiveLinks()) {
this.activateLink(link, 'collapse');
}
setTimeout(() => {
this.bodyRemove('hide');
this.bodyAdd('hidden');
}, 300);
}
}
/**
* Close the (unpinned) sidenav when the window is resized.
*/
private onResize(): void {
if (this.bodyHas('show') && !this.bodyHas('pinned')) {
this.bodyRemove('show');
this.bodyAdd('hidden');
}
}
/**
* Pin & unpin the sidenav when the pin button is toggled.
*/
private onToggle(event: Event): void {
event.preventDefault();
if (this.state.get('pinned')) {
this.unpin();
} else {
this.pin();
}
}
private onMobileToggle(event: Event): void {
event.preventDefault();
if (this.bodyHas('hidden')) {
this.show();
} else {
this.hide();
}
}
}
export function initSideNav() {
const element = getElement<HTMLAnchorElement>('sidebarMenu');
if (element !== null && document.body.clientWidth < breakpoints.lg) {
element.addEventListener('shown.bs.collapse', () => toggleBodyPosition('fixed'));
element.addEventListener('hidden.bs.collapse', () => toggleBodyPosition('relative'));
for (const sidenav of getElements<HTMLDivElement>('.sidenav')) {
new SideNav(sidenav);
}
}

View File

@ -56,6 +56,13 @@ export function isTruthy<V extends string | number | boolean | null | undefined>
return false;
}
/**
* Type guard to determine if a value is an `Element`.
*/
export function isElement(obj: Element | null | undefined): obj is Element {
return typeof obj !== null && typeof obj !== 'undefined';
}
/**
* Retrieve the CSRF token from cookie storage.
*/
@ -152,6 +159,22 @@ export function getElement<E extends HTMLElement>(id: string): Nullable<E> {
return document.getElementById(id) as Nullable<E>;
}
export function removeElements(...selectors: string[]): void {
for (const element of getElements(...selectors)) {
element.remove();
}
}
export function elementWidth<E extends HTMLElement>(element: Nullable<E>): number {
let width = 0;
if (element !== null) {
const style = getComputedStyle(element);
const pre = style.width.replace('px', '');
width = parseFloat(pre);
}
return width;
}
/**
* scrollTo() wrapper that calculates a Y offset relative to `element`, but also factors in an
* offset relative to div#content-title. This ensures we scroll to the element, but leave enough

View File

@ -2,3 +2,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
@import '../node_modules/@mdi/font/css/materialdesignicons.min.css';
@import '../node_modules/flatpickr/dist/flatpickr.css';
@import '../node_modules/simplebar/dist/simplebar.css';

View File

@ -1,9 +1,11 @@
// Netbox-specific Styles and Overrides.
@use 'sass:map';
@import './sidenav.scss';
:root {
--nbx-sidebar-bg: #{$gray-200};
--nbx-sidebar-scroll: #{$gray-500};
--nbx-sidebar-link-color: #{$gray-800};
--nbx-sidebar-link-hover-bg: #{$blue-100};
--nbx-sidebar-title-color: #{$text-muted};
@ -21,9 +23,11 @@
--nbx-cable-termination-border-color: #{$gray-300};
--nbx-search-filter-border-left-color: #{$gray-300};
--nbx-color-mode-toggle-color: #{$primary};
--nbx-sidenav-pin-color: #{$orange};
&[data-netbox-color-mode='dark'] {
--nbx-sidebar-bg: #{$gray-900};
--nbx-sidebar-scroll: #{$gray-700};
--nbx-sidebar-link-color: #{$gray-100};
--nbx-sidebar-link-hover-bg: #{rgba($blue-300, 0.15)};
--nbx-sidebar-title-color: #{$gray-600};
@ -41,6 +45,7 @@
--nbx-cable-termination-border-color: #{$gray-700};
--nbx-search-filter-border-left-color: #{$gray-600};
--nbx-color-mode-toggle-color: #{$yellow-300};
--nbx-sidenav-pin-color: #{$yellow};
}
}
@ -119,6 +124,13 @@ small {
background: transparent escape-svg($btn-close-bg) center / $btn-close-width auto no-repeat;
}
.btn.btn-ghost-#{$color} {
color: $value;
&:hover {
background-color: rgba($value, 0.12);
}
}
// Use Bootstrap's method of coloring the .alert-link class automatically.
// See: https://github.com/twbs/bootstrap/blob/2bdbb42dcf6bfb99b5e9e5444d9e64589eb8c08f/scss/_alert.scss#L50-L52
.toast.bg-#{$color},
@ -160,6 +172,34 @@ table td > .progress {
min-width: 6rem;
}
.nav-mobile {
display: none;
flex-direction: column;
align-items: center;
justify-content: space-between;
width: 100%;
@include media-breakpoint-down(lg) {
display: flex;
}
.nav-mobile-top {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
}
.search-container {
display: flex;
width: 100%;
@include media-breakpoint-down(lg) {
display: none;
}
}
.card > .table.table-flush {
margin-bottom: 0;
overflow: hidden;
@ -359,6 +399,8 @@ div.title-container {
nav.search {
background-color: var(--nbx-body-bg);
// Don't overtake dropdowns
z-index: 999;
form button.dropdown-toggle {
border-color: $input-border-color;
font-weight: $input-group-addon-font-weight;
@ -374,6 +416,16 @@ nav.search {
}
}
main.layout {
display: flex;
flex-wrap: nowrap;
height: 100vh;
height: -webkit-fill-available;
max-height: 100vh;
overflow-x: auto;
overflow-y: hidden;
}
main.login-container {
display: flex;
height: calc(100vh - 4rem);
@ -390,6 +442,18 @@ main.login-container {
}
}
.footer {
padding-top: map.get($spacers, 4);
padding-right: 0;
padding-bottom: map.get($spacers, 3);
padding-left: 0;
@include media-breakpoint-down(md) {
// Pad the bottom of the footer on mobile devices to account for mobile browser controls.
margin-bottom: 8rem;
}
}
footer.login-footer {
height: 4rem;
margin-top: auto;
@ -425,7 +489,6 @@ h3.accordion-item-title,
h4.accordion-item-title,
h5.accordion-item-title,
h6.accordion-item-title {
// padding: 0 0.5rem;
padding: 0.25rem 0.5rem;
font-weight: $font-weight-bold;
text-transform: uppercase;
@ -474,7 +537,7 @@ li.dropdown-item.dropdown-item-btns {
height: calc(100vh - 48px);
padding-top: 0.5rem;
overflow-x: hidden;
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
overflow-y: auto; // Scrollable contents if viewport is shorter than content.
}
.navbar-brand {
@ -498,13 +561,16 @@ nav.nav.nav-pills {
// Ensure the content container is full-height, and that the content block is also full height so
// that the footer is always at the bottom.
div.content-container {
position: relative;
min-height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
width: calc(100% - 4.5rem);
overflow-x: hidden;
overflow-y: auto;
@include media-breakpoint-up(md) {
margin-left: $sidebar-width;
@include media-breakpoint-down(lg) {
width: 100%;
}
div.content {
@ -527,7 +593,7 @@ div.content-container {
top: 0;
bottom: 0;
left: 0;
z-index: 100; /* Behind the navbar */
z-index: 100; // Behind the navbar
border-right: 1px solid $border-color;
background-color: var(--nbx-sidebar-bg);
max-height: 100%;
@ -632,6 +698,11 @@ div.content-container {
}
.search-obj-selector {
@include media-breakpoint-down(lg) {
// Limit the height and enable scrolling on mobile devices.
max-height: 75vh;
overflow-y: auto;
}
.dropdown-item,
.dropdown-header {
font-size: $font-size-sm;
@ -807,7 +878,7 @@ div.field-group:not(:first-of-type) {
label.required {
font-weight: $font-weight-bold;
&::after {
&:after {
font-family: 'Material Design Icons';
content: '\f06C4';
font-weight: normal;
@ -827,29 +898,34 @@ div.bulk-buttons {
display: flex;
justify-content: space-between;
margin: $spacer / 2 0;
// Each group of buttons needs to be contained separately for alignment purposes. This way, you
// can put some buttons in a group that aligns left, and other buttons in a group that aligns
// right.
& > div.bulk-button-group {
> div.bulk-button-group {
display: flex;
flex-wrap: wrap;
&:first-of-type:not(:last-of-type) {
// If there are multiple bulk button groups and this is the first, the first button in the
// group should *not* have left spacing applied, so the button group aligns with the rest
// of the page elements.
& > *:first-child {
> *:first-child {
margin-left: 0;
}
}
&:last-of-type:not(:first-of-type) {
// If there are multiple bulk button groups and this is the last, the last button in the
// group should *not* have right spacing applied, so the button group aligns with the rest
// of the page elements.
& > *:last-child {
> *:last-child {
margin-right: 0;
}
}
// However, the rest of the buttons should have spacing applied in all directions.
& > * {
> * {
margin: $spacer / 4;
}
}
@ -963,12 +1039,12 @@ html {
&[data-netbox-path='/'] {
.content-container,
.search {
background-color: $gray-100;
background-color: $gray-100 !important;
}
&[data-netbox-color-mode='dark'] {
.content-container,
.search {
background-color: $darkest;
background-color: $darkest !important;
}
}
}

View File

@ -71,6 +71,10 @@ $spacing-s: $input-padding-x;
border-color: currentColor;
}
}
.placeholder .depth {
// Don't show the depth indicator outside of the menu.
display: none;
}
span.placeholder > *,
span.placeholder {
line-height: $input-line-height;

View File

@ -0,0 +1,420 @@
@use 'sass:map';
@mixin parent-link {
.navbar-nav .nav-item .nav-link[data-bs-toggle] {
@content;
}
}
@mixin child-link {
.collapse .nav .nav-item .nav-link {
@content;
}
}
@mixin sidenav-open {
body[data-sidenav-show],
body[data-sidenav-pinned] {
.sidenav {
@content;
}
}
}
@mixin sidenav-closed {
body[data-sidenav-hide],
body[data-sidenav-hidden] {
.sidenav {
@content;
}
}
}
@mixin sidenav-pinned {
body[data-sidenav-pinned] {
.sidenav {
@content;
}
}
}
@mixin sidenav-show {
body[data-sidenav-show] {
.sidenav {
@content;
}
}
}
@mixin sidenav-hide {
body[data-sidenav-hide] {
.sidenav {
@content;
}
}
}
@mixin sidenav-peek {
.g-sidenav-show:not(.g-sidenav-pinned) {
.sidenav {
@content;
}
}
}
$transition-100ms-ease-in-out: all 0.1s ease-in-out;
.sidenav {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 1050;
display: block;
width: 100%;
max-width: $sidenav-width-closed;
padding-top: 0;
padding-right: 0;
padding-left: 0;
background-color: var(--nbx-sidebar-bg);
border-right: 1px solid $border-color;
transition: $transition-100ms-ease-in-out;
// Media fixes for mobile resolutions.
@include media-breakpoint-down(lg) {
transform: translateX(-$sidenav-width-closed);
+ .content-container[class] {
margin-left: 0;
}
.profile-button-container[class] {
display: block;
}
}
.profile-button-container {
display: none;
padding: $sidenav-link-spacing-y $sidenav-link-spacing-x;
}
+ .content-container {
margin-left: $sidenav-width-closed;
transition: $transition-100ms-ease-in-out;
}
// Navbar brand
.sidenav-brand {
margin-right: 0;
}
.sidenav-inner {
padding-right: $sidenav-spacing-x;
padding-left: $sidenav-spacing-x;
@include media-breakpoint-up(md) {
padding-right: 0;
padding-left: 0;
}
}
.sidenav-brand-img,
.sidenav-brand > img {
max-width: 100%;
max-height: calc(#{$sidenav-width-open} - 1rem);
}
.navbar-heading {
padding-top: $nav-link-padding-y;
padding-bottom: $nav-link-padding-y;
font-size: $font-size-xs;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sidenav-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
height: 78px;
padding: $spacer;
transition: $transition-100ms-ease-in-out;
}
.sidenav-toggle {
display: none;
}
.sidenav-collapse {
display: flex;
flex: 1;
flex-direction: column;
align-items: stretch;
padding-right: $sidenav-spacing-x;
padding-left: $sidenav-spacing-x;
margin-right: -$sidenav-spacing-x;
margin-left: -$sidenav-spacing-x;
> * {
min-width: 100%;
}
@include media-breakpoint-up(md) {
margin-right: 0;
margin-left: 0;
}
}
// Child Link nav-item
.nav .nav-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
@include child-link() {
width: 100%;
padding-top: $sidenav-link-spacing-y / 2.675;
padding-right: map.get($spacers, 1);
padding-bottom: $sidenav-link-spacing-y / 2.675;
/* stylelint-disable */
padding-left: $sidenav-link-spacing-x + $sidenav-icon-width + $sidenav-link-spacing-x / 4;
/* stylelint-enable */
margin-top: $sidenav-link-spacing-y / 3.3;
margin-bottom: $sidenav-link-spacing-y / 3.3;
font-size: $font-size-xs;
border-top-right-radius: $border-radius;
border-bottom-right-radius: $border-radius;
.sidenav-normal {
color: $text-muted;
&:hover {
opacity: 0.8;
}
}
.sidenav-mini-icon {
width: $sidenav-link-spacing-x;
text-align: center;
transition: $transition-100ms-ease-in-out;
}
}
@include parent-link() {
width: unset;
height: 100%;
font-weight: $font-weight-bold;
&.active {
color: $accordion-button-active-color;
background: $accordion-button-active-bg;
}
&:after {
display: inline-block;
margin-left: auto;
/* stylelint-disable */
font-family: 'Material Design Icons';
/* stylelint-enable */
font-style: normal;
font-weight: 700;
font-variant: normal;
color: $text-muted;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
content: '\f0142';
transition: $transition-100ms-ease-in-out;
}
// Expanded
&[aria-expanded='true'] {
&.active:after {
color: $accordion-button-active-color;
}
&:after {
color: $primary;
transform: rotate(90deg);
}
}
.nav-link-text {
padding-left: 0.25rem;
transition: $transition-100ms-ease-in-out;
}
}
.navbar-nav {
flex-direction: column;
margin-right: -$sidenav-spacing-x;
margin-left: -$sidenav-spacing-x;
.nav-item {
margin-top: 2px;
&.disabled {
cursor: not-allowed;
opacity: 0.8;
}
// All Links
.nav-link {
display: flex;
align-items: center;
width: 100%;
padding: $sidenav-link-spacing-y $sidenav-link-spacing-x;
font-size: $font-size-sm;
white-space: nowrap;
transition: $transition-100ms-ease-in-out;
// &.disabled {
// opacity: 0.8;
// }
&.active {
position: relative;
color: var(--nbx-sidebar-link-hover-bg);
background-color: var(--nbx-sidebar-link-hover-bg);
}
// Icon
> i {
min-width: $sidenav-icon-width;
font-size: calc(45px / 2);
text-align: center;
}
}
}
.nav-group-label {
display: block;
font-size: $font-size-xs;
font-weight: $font-weight-bold;
color: $primary;
text-transform: uppercase;
white-space: nowrap;
}
}
}
@include sidenav-pinned() {
.sidenav-toggle-icon {
color: var(--nbx-sidenav-pin-color);
transform: rotate(90deg);
}
@include media-breakpoint-up(xl) {
+ .content-container {
margin-left: $sidenav-width-open;
}
}
}
@include sidenav-peek() {
.sidenav-toggle-icon {
transform: rotate(0deg);
}
}
@include sidenav-open() {
max-width: $sidenav-width-open;
.sidenav-brand,
.navbar-heading {
display: block;
}
.sidenav-brand {
opacity: 1;
transform: translateX(0);
}
.sidenav-brand-icon {
position: absolute;
opacity: 0;
}
@include media-breakpoint-down(lg) {
transform: translateX(0);
}
}
@include sidenav-closed() {
.sidenav-header {
padding: $spacer * 0.5;
}
.sidenav-brand {
position: absolute;
opacity: 0;
transform: translateX(-150%);
}
.sidenav-brand-icon {
opacity: 1;
}
.navbar-nav > .nav-item {
> .nav-link {
&:after {
content: '';
}
}
}
.nav-item .collapse {
display: none;
}
.nav-link-text {
opacity: 0;
}
@include parent-link() {
&.active {
margin-right: 0;
margin-left: 0;
border-radius: unset;
}
}
}
@include sidenav-show() {
.sidenav-brand {
display: block;
}
.nav-item .collapse {
height: auto;
transition: $transition-100ms-ease-in-out;
}
.nav-item .nav-link .nav-link-text {
opacity: 1;
}
.nav-item .sidenav-mini-icon {
opacity: 0;
}
@include media-breakpoint-up(lg) {
.sidenav-toggle {
display: inline-block;
}
}
}
.simplebar-track.simplebar-vertical {
right: 0;
width: 6px;
background-color: transparent;
.simplebar-scrollbar:before {
right: 0;
width: 3px;
background: var(--nbx-sidebar-scroll);
border-radius: $border-radius;
}
&.simplebar-hover .simplebar-scrollbar:before {
width: 5px;
}
}

View File

@ -122,11 +122,13 @@ $theme-color-addons: (
'pink-900': $pink-900,
);
/* stylelint-disable */
$font-family-sans-serif: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
$font-family-monospace: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
/* stylelint-enable */
// This is the same value as the default from Bootstrap, but it needs to be in scope prior to
// importing _variables.scss from Bootstrap.
@ -137,3 +139,12 @@ $accordion-padding-x: 0.8125rem;
$sidebar-width: 280px;
$sidebar-bottom-height: 4rem;
// Sidebar/Sidenav
$sidenav-width-closed: 4rem;
$sidenav-width-open: 16rem;
$sidenav-icon-width: 2rem;
$sidenav-link-px: 1rem;
$sidenav-spacing-x: 1.5rem;
$sidenav-link-spacing-x: 1rem;
$sidenav-link-spacing-y: 0.675rem;

View File

@ -1,7 +1,7 @@
// Dark Mode Theme Variables and Overrides.
@use 'sass:map';
@import './theme-base.scss';
@import './theme-base';
$primary: $blue-300;
$secondary: $gray-500;
@ -175,13 +175,11 @@ $accordion-bg: transparent;
$accordion-border-color: $border-color;
$accordion-button-color: $accordion-color;
$accordion-button-bg: $accordion-bg;
$accordion-body-active-bg: rgba($blue-300, 0.2);
$accordion-button-active-bg: rgba($blue-300, 0.25);
$accordion-button-active-color: $gray-300;
$accordion-button-active-bg: shade-color($blue-300, 10%);
$accordion-button-active-color: color-contrast($accordion-button-active-bg);
$accordion-button-focus-border-color: $input-focus-border-color;
$accordion-icon-color: $accordion-color;
$accordion-icon-active-color: $accordion-button-active-color;
$accordion-button-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$accordion-icon-color}'><path fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/></svg>");
$accordion-button-active-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$accordion-icon-active-color}'><path fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/></svg>");

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@
<meta charset="UTF-8" />
<meta
name="viewport"
content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width"
content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width, viewport-fit=cover"
/>
{# Page title #}

View File

@ -6,103 +6,51 @@
{% load static %}
{% block layout %}
<div class="container-fluid px-0">
<main class="ms-sm-auto">
<main class="layout">
{# Sidebar #}
<nav id="sidebar-menu" class="d-md-block sidebar collapse px-0" data-simplebar>
{# Sidebar content #}
<div class="position-sticky">
{# Logo #}
<div class="py-2">
<a class="sidebar-logo d-none d-md-flex justify-content-center" href="{% url 'home' %}">
<img src="{% static 'netbox_logo.svg' %}" alt="NetBox logo" />
</a>
</div>
<ul class="nav flex-column">
{# Search bar for collapsed menu #}
<div class="d-block d-md-none mx-1 my-3 search-container">
{% search_options %}
</div>
<div class="d-flex d-md-none mx-1 my-3 justify-content-center justify-content-md-end order-last order-md-0">
{% include 'inc/profile_button.html' %}
</div>
{# Navigation menu #}
{% nav %}
</ul>
</div>
{# Sidebar footer #}
<div class="d-flex flex-column container-fluid mt-auto justify-content-end sidebar-bottom">
<nav class="nav">
{# Documentation #}
<a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank">
<i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# REST API #}
<a type="button" class="nav-link" href="{% url 'api-root' %}" target="_blank">
<i title="REST API" class="mdi mdi-cloud-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# API docs #}
<a type="button" class="nav-link" href="{% url 'api_docs' %}" target="_blank">
<i title="REST API documentation" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# GraphQL API #}
{% if settings.GRAPHQL_ENABLED %}
<a type="button" class="nav-link" href="{% url 'graphql' %}" target="_blank">
<i title="GraphQL API" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{% endif %}
{# GitHub #}
<a type="button" class="nav-link" href="https://github.com/netbox-community/netbox" target="_blank">
<i title="Source Code" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# NetDev Slack #}
<a type="button" class="nav-link" href="https://netdev.chat/" target="_blank">
<i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
</nav>
</div>
</nav>
{% include 'base/sidenav.html' %}
{# Body #}
<div class="content-container">
{# Top bar #}
<nav class="navbar navbar-light sticky-top flex-md-nowrap p-3 search container-fluid">
<div class="d-md-none w-100 d-flex justify-content-between align-items-center my-3">
<a class="p-2 sidebar-logo d-block d-md-none" href="{% url 'home' %}">
<img src="{% static 'netbox_logo.svg' %}" alt="NetBox logo" width="100%" />
</a>
<button
type="button"
aria-expanded="false"
data-bs-toggle="collapse"
aria-controls="sidebar-menu"
data-bs-target="#sidebar-menu"
aria-label="Toggle Navigation"
class="navbar-toggler position-relative collapsed"
>
<span class="navbar-toggler-icon"></span>
</button>
<nav class="navbar navbar-light sticky-top flex-md-nowrap ps-6 p-3 search container-fluid">
{# Mobile Navigation #}
<div class="nav-mobile">
<div class="nav-mobile-top">
<a class="sidebar-logo p-2 d-block" href="{% url 'home' %}">
<img src="{% static 'netbox_logo.svg' %}" alt="NetBox logo" width="75%" />
</a>
<button type="button" aria-label="Toggle Navigation" class="navbar-toggler sidenav-toggle-mobile">
<span class="navbar-toggler-icon"></span>
</button>
</div>
<div class="d-flex my-1 flex-grow-1 justify-content-center">
{% search_options %}
</div>
</div>
<div class="d-none d-md-flex w-100 search-container">
{% search_options %}
{% include 'inc/profile_button.html' %}
{# Desktop Navigation #}
<div class="row search-container">
{# Empty spacer column to ensure search is centered. #}
<div class="col-3 d-flex flex-grow-1 ps-0"></div>
{# Search bar #}
<div class="col-6 d-flex flex-grow-1 justify-content-center">
{% search_options %}
</div>
{# Proflie/login button #}
<div class="col-3 d-flex flex-grow-1 pe-0 justify-content-end">
{% include 'inc/profile_button.html' %}
</div>
</div>
</nav>
{% if settings.BANNER_TOP %}
@ -152,12 +100,52 @@
{% endif %}
{# Page footer #}
<footer class="footer container-fluid pb-3 pt-4 px-0">
<div class="row align-items-center justify-content-end mx-0">
<div class="col text-center small text-muted">
<footer class="footer container-fluid">
<div class="row align-items-center justify-content-between mx-0">
{# Docs & Community Links #}
<div class="col-sm-12 col-md-auto">
<nav class="nav justify-content-center justify-content-lg-start">
{# Documentation #}
<a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank">
<i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# REST API #}
<a type="button" class="nav-link" href="{% url 'api-root' %}" target="_blank">
<i title="REST API" class="mdi mdi-cloud-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# API docs #}
<a type="button" class="nav-link" href="{% url 'api_docs' %}" target="_blank">
<i title="REST API documentation" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# GraphQL API #}
{% if settings.GRAPHQL_ENABLED %}
<a type="button" class="nav-link" href="{% url 'graphql' %}" target="_blank">
<i title="GraphQL API" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{% endif %}
{# GitHub #}
<a type="button" class="nav-link" href="https://github.com/netbox-community/netbox" target="_blank">
<i title="Source Code" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# NetDev Slack #}
<a type="button" class="nav-link" href="https://netdev.chat/" target="_blank">
<i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
</nav>
</div>
{# System Info #}
<div class="col-sm-12 col-md-auto text-center text-lg-end small text-muted">
<span class="fw-light d-block d-md-inline">{% annotated_now %} {% now 'T' %}</span>
<span class="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span>
</div>
</div>
</footer>

View File

@ -0,0 +1,41 @@
{% load nav %}
{% load static %}
<nav class="sidenav" id="sidenav" data-simplebar>
<div class="sidenav-header">
{# Brand #}
{# Full Logo #}
<a class="sidenav-brand" href="/">
<img src="{% static 'netbox_logo.svg' %}" height="48" class="sidenav-brand-img" alt="NetBox Logo">
</a>
{# Icon Logo #}
<a class="sidenav-brand-icon" href="/">
<img src="{% static 'netbox_icon.svg' %}" height="48" class="sidenav-brand-img" alt="NetBox Logo">
</a>
{# Pin/Unpin Toggle #}
<button class="btn btn-sm btn-ghost-primary sidenav-toggle">
<div class="sidenav-toggle-icon">
<i class="mdi mdi-pin"></i>
</div>
</button>
</div>
<div class="sidenav-inner h-100 mb-auto">
{# Collapse #}
<div class="collapse sidenav-collapse">
{# Nav Items #}
{% nav %}
</div>
</div>
<div class="profile-button-container">
{% include 'inc/profile_button.html' %}
</div>
</nav>

View File

@ -95,7 +95,7 @@
<td>
{% if object.front_image %}
<a href="{{ object.front_image.url }}">
<img src="{{ object.front_image.url }}" alt="{{ object.front_image.name }}" class="img-responsive" />
<img src="{{ object.front_image.url }}" alt="{{ object.front_image.name }}" class="img-fluid" />
</a>
{% else %}
<span class="text-muted">&mdash;</span>
@ -107,7 +107,7 @@
<td>
{% if object.rear_image %}
<a href="{{ object.rear_image.url }}">
<img src="{{ object.rear_image.url }}" alt="{{ object.rear_image.name }}" class="img-responsive" />
<img src="{{ object.rear_image.url }}" alt="{{ object.rear_image.name }}" class="img-fluid" />
</a>
{% else %}
<span class="text-muted">&mdash;</span>

View File

@ -8,13 +8,13 @@
title="Configure Table"
data-bs-target="#{{ table_modal }}"
class="btn btn-sm btn-outline-dark"
>
>
<i class="mdi mdi-cog"></i> Configure Table
</button>
</div>
{% endif %}
</div>
<div class="col col-md-4 d-flex noprint table-controls">
<div class="col col-12 col-lg-4 my-3 my-lg-0 d-flex noprint table-controls">
<div class="input-group input-group-sm">
<input
type="text"

View File

@ -13,7 +13,7 @@
{% for obj_type in results %}
<div class="card">
<h5 class="card-header">{{ obj_type.name|bettertitle }}</h5>
<div class="card-body">
<div class="card-body table-responsive">
{% render_table obj_type.table 'inc/table.html' %}
</div>
<div class="card-footer text-end">

View File

@ -1,79 +1,58 @@
{% load helpers %}
<div id="sidenav-accordion" class="accordion accordion-flush nav-item">
{% for menu in nav_items %}
<ul class="navbar-nav">
{% for menu in nav_items %}
<li class="nav-item">
<a class="nav-link" href="#menu{{ menu.label }}" data-bs-toggle="collapse" role="button" aria-expanded="false" aria-controls="menu{{ menu.label }}">
<i class="{{ menu.icon_class }}"></i>
<span class="nav-link-text">{{ menu.label }}</span>
</a>
<div class="collapse" id="menu{{ menu.label }}">
<ul class="nav nav-sm flex-column">
{# Main Collapsible Menu #}
<div class="accordion-item">
<a
href="#"
role="button"
aria-expanded="true"
data-bs-toggle="collapse"
data-bs-target="#{{ menu.label|lower }}"
class="d-flex justify-content-between align-items-center accordion-button nav-link collapsed">
<span class="fw-bold sidebar-nav-link">
<i class="{{ menu.icon_class }} me-1 opacity-50"></i>
{{ menu.label }}
</span>
</a>
<div id="{{ menu.label|lower }}" class="accordion-collapse collapse" data-bs-parent="#sidenav-accordion">
<div class="multi-level accordion-body px-0">
{% for group in menu.groups %}
{# Within each main menu, there are groups of menu items #}
<div class="flex-column nav">
<h6 class="accordion-item-title">{{ group.label }}</h6>
{% for item in group.items %}
{# Each Menu Item #}
<div class="nav-item d-flex justify-content-between align-items-center">
{# Menu Link with Text #}
{% if request.user|has_perms:item.permissions %}
<a class="nav-link flex-grow-1" href="{% url item.link %}">
{{ item.link_text }}
</a>
{# Menu item buttons (if any) #}
{% if item.buttons %}
<div class="btn-group ps-1">
{% for button in item.buttons %}
{% if request.user|has_perms:button.permissions %}
<a class="btn btn-sm btn-{{ button.color }} lh-1" href="{% url button.link %}" title="{{ button.title }}">
<i class="{{ button.icon_class }}"></i>
</a>
{% endif %}
{% for group in menu.groups %}
{# Within each main menu, there are groups of menu items #}
<li class="nav-item">
<div class="nav-link">
{# Group Label #}
<span class="nav-group-label">{{ group.label }}</span>
</div>
</li>
{% for item in group.items %}
{# Each Item #}
{% if request.user|has_perms:item.permissions %}
<li class="nav-item">
<a href="{% url item.link %}" class="nav-link">
<span class="sidenav-normal">{{ item.link_text }}</span>
</a>
{# Menu item buttons (if any) #}
{% if item.buttons %}
<div class="btn-group px-2">
{% for button in item.buttons %}
{% if request.user|has_perms:button.permissions %}
<a class="btn btn-sm btn-{{ button.color }} lh-1" href="{% url button.link %}" title="{{ button.title }}">
<i class="{{ button.icon_class }}"></i>
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
</li>
{% else %}
{# Display a disabled link (no permission) #}
<li class="nav-item disabled">
<a href="#" class="nav-link disabled" aria-disabled="true" disabled>
<i class='mdi mdi-lock small'></i>
<span class="sidenav-normal">{{ item.link_text }}</span>
</a>
</li>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% else %}
{# Display a disabled link (no permission) #}
<a class="nav-link flex-grow-1 disabled">
{{ item.link_text }}
</a>
{% endif %}
</div>
{% endfor %}
{% endfor %}
</ul>
</div>
{# Show a divider if not the last group #}
{% if forloop.counter != menu.groups|length %}
<hr class="dropdown-divider my-2" />
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
</li>
{% endfor %}
</ul>

View File

@ -1,4 +1,4 @@
<form class="input-group w-100" action="{% url 'search' %}" method="get">
<form class="input-group" action="{% url 'search' %}" method="get">
<input
name="q"
type="text"
@ -7,45 +7,44 @@
class="form-control"
value="{{ request.GET.q }}"
/>
<input name="obj_type" hidden type="text" class="search-obj-type" />
<span class="input-group-text search-obj-selected">All Objects</span>
<button
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
class="btn btn-outline-secondary dropdown-toggle"
>
<button type="button" aria-expanded="false" data-bs-toggle="dropdown" class="btn btn-outline-secondary dropdown-toggle">
<i class="mdi mdi-filter-variant"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end search-obj-selector">
{% for option in options %} {% if option.items|length == 0 %}
<li>
<button
class="dropdown-item"
type="button"
data-search-value="{{ option.value }}"
>
{{ option.label }}
</button>
</li>
{% else %}
<li><h6 class="dropdown-header">{{ option.label }}</h6></li>
{% endif %} {% for item in option.items %}
<li>
<button
class="dropdown-item"
type="button"
data-search-value="{{ item.value }}"
>
{{ item.label }}
</button>
</li>
{% endfor %} {% if forloop.counter != options|length %}
<li><hr class="dropdown-divider" /></li>
{% endif %} {% endfor %}
{% for option in options %}
{% if option.items|length == 0 %}
<li>
<button class="dropdown-item" type="button" data-search-value="{{ option.value }}">
{{ option.label }}
</button>
</li>
{% else %}
<li><h6 class="dropdown-header">{{ option.label }}</h6></li>
{% endif %}
{% for item in option.items %}
<li>
<button class="dropdown-item" type="button" data-search-value="{{ item.value }}">
{{ item.label }}
</button>
</li>
{% endfor %}
{% if forloop.counter != options|length %}
<li><hr class="dropdown-divider" /></li>
{% endif %}
{% endfor %}
</ul>
<button class="btn btn-primary" type="submit">
<i class="mdi mdi-magnify"></i>
</button>
</form>