mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
add javascript
This commit is contained in:
18
netbox/project-static/src/dateSelector.ts
Normal file
18
netbox/project-static/src/dateSelector.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import flatpickr from 'flatpickr';
|
||||||
|
|
||||||
|
export function initDateSelector(): void {
|
||||||
|
flatpickr('.date-picker', { allowInput: true });
|
||||||
|
flatpickr('.datetime-picker', {
|
||||||
|
allowInput: true,
|
||||||
|
enableSeconds: true,
|
||||||
|
enableTime: true,
|
||||||
|
time_24hr: true,
|
||||||
|
});
|
||||||
|
flatpickr('.time-picker', {
|
||||||
|
allowInput: true,
|
||||||
|
enableSeconds: true,
|
||||||
|
enableTime: true,
|
||||||
|
noCalendar: true,
|
||||||
|
time_24hr: true,
|
||||||
|
});
|
||||||
|
}
|
97
netbox/project-static/src/forms.ts
Normal file
97
netbox/project-static/src/forms.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { getElements, scrollTo } from './util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get form data from a form element and transform it into a body usable by fetch.
|
||||||
|
*
|
||||||
|
* @param element Form element
|
||||||
|
* @returns Fetch body
|
||||||
|
*/
|
||||||
|
export function getFormData(element: HTMLFormElement): URLSearchParams {
|
||||||
|
const formData = new FormData(element);
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
for (const [k, v] of formData) {
|
||||||
|
body.append(k, v as string);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the value of the number input field based on the selection of the dropdown.
|
||||||
|
*/
|
||||||
|
export function initSpeedSelector(): void {
|
||||||
|
for (const element of getElements<HTMLAnchorElement>('a.set_speed')) {
|
||||||
|
if (element !== null) {
|
||||||
|
function handleClick(event: Event) {
|
||||||
|
// Don't reload the page (due to href="#").
|
||||||
|
event.preventDefault();
|
||||||
|
// Get the value of the `data` attribute on the dropdown option.
|
||||||
|
const value = element.getAttribute('data');
|
||||||
|
// Find the input element referenced by the dropdown element.
|
||||||
|
const input = document.getElementById(element.target) as Nullable<HTMLInputElement>;
|
||||||
|
if (input !== null && value !== null) {
|
||||||
|
// Set the value of the input field to the `data` attribute's value.
|
||||||
|
input.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.addEventListener('click', handleClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach an event listener to each form's submitter (button[type=submit]). When called, the
|
||||||
|
* callback checks the validity of each form field and adds the appropriate Bootstrap CSS class
|
||||||
|
* based on the field's validity.
|
||||||
|
*/
|
||||||
|
export function initForms() {
|
||||||
|
for (const form of getElements('form')) {
|
||||||
|
const { elements } = form;
|
||||||
|
// Find each of the form's submitters. Most object edit forms have a "Create" and
|
||||||
|
// a "Create & Add", so we need to add a listener to both.
|
||||||
|
const submitters = form.querySelectorAll('button[type=submit]');
|
||||||
|
|
||||||
|
function callback(event: Event): void {
|
||||||
|
// Track the names of each invalid field.
|
||||||
|
const invalids = new Set<string>();
|
||||||
|
|
||||||
|
for (const el of elements) {
|
||||||
|
const element = (el as unknown) as FormControls;
|
||||||
|
|
||||||
|
if (!element.validity.valid) {
|
||||||
|
invalids.add(element.name);
|
||||||
|
|
||||||
|
// If the field is invalid, but contains the .is-valid class, remove it.
|
||||||
|
if (element.classList.contains('is-valid')) {
|
||||||
|
element.classList.remove('is-valid');
|
||||||
|
}
|
||||||
|
// If the field is invalid, but doesn't contain the .is-invalid class, add it.
|
||||||
|
if (!element.classList.contains('is-invalid')) {
|
||||||
|
element.classList.add('is-invalid');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the field is valid, but contains the .is-invalid class, remove it.
|
||||||
|
if (element.classList.contains('is-invalid')) {
|
||||||
|
element.classList.remove('is-invalid');
|
||||||
|
}
|
||||||
|
// If the field is valid, but doesn't contain the .is-valid class, add it.
|
||||||
|
if (!element.classList.contains('is-valid')) {
|
||||||
|
element.classList.add('is-valid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalids.size !== 0) {
|
||||||
|
// If there are invalid fields, pick the first field and scroll to it.
|
||||||
|
const firstInvalid = elements.namedItem(Array.from(invalids)[0]) as Element;
|
||||||
|
scrollTo(firstInvalid);
|
||||||
|
|
||||||
|
// If the form has invalid fields, don't submit it.
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const submitter of submitters) {
|
||||||
|
// Add the event listener to each submitter.
|
||||||
|
submitter.addEventListener('click', callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
38
netbox/project-static/src/global.d.ts
vendored
Normal file
38
netbox/project-static/src/global.d.ts
vendored
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
type Nullable<T> = T | null;
|
||||||
|
|
||||||
|
type APIAnswer<T> = {
|
||||||
|
count: number;
|
||||||
|
next: Nullable<string>;
|
||||||
|
previous: Nullable<string>;
|
||||||
|
results: T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type APIError = {
|
||||||
|
error: string;
|
||||||
|
exception: string;
|
||||||
|
netbox_version: string;
|
||||||
|
python_version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type APIObjectBase = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface APIReference {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
url: string;
|
||||||
|
_depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ObjectWithGroup extends APIObjectBase {
|
||||||
|
group: Nullable<APIReference>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const messages: string[];
|
||||||
|
|
||||||
|
type FormControls = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
13
netbox/project-static/src/index.ts
Normal file
13
netbox/project-static/src/index.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// const jquery = require("jquery");
|
||||||
|
/* // @ts-expect-error */
|
||||||
|
// window.$ = window.jQuery = jquery;
|
||||||
|
// require("jquery-ui");
|
||||||
|
// require("select2");
|
||||||
|
// require("./js/forms");
|
||||||
|
|
||||||
|
require('babel-polyfill');
|
||||||
|
require('@popperjs/core');
|
||||||
|
require('bootstrap');
|
||||||
|
require('clipboard');
|
||||||
|
require('flatpickr');
|
||||||
|
require('./netbox');
|
101
netbox/project-static/src/netbox.ts
Normal file
101
netbox/project-static/src/netbox.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { Tooltip } from 'bootstrap';
|
||||||
|
import Masonry from 'masonry-layout';
|
||||||
|
import { initApiSelect, initStaticSelect, initColorSelect } from './select';
|
||||||
|
import { initDateSelector } from './dateSelector';
|
||||||
|
import { initMessageToasts } from './toast';
|
||||||
|
import { initSpeedSelector, initForms } from './forms';
|
||||||
|
import { initSearchBar } from './search';
|
||||||
|
|
||||||
|
const INITIALIZERS = [
|
||||||
|
initSearchBar,
|
||||||
|
initMasonry,
|
||||||
|
bindReslug,
|
||||||
|
initApiSelect,
|
||||||
|
initStaticSelect,
|
||||||
|
initDateSelector,
|
||||||
|
initSpeedSelector,
|
||||||
|
initColorSelect,
|
||||||
|
] as (() => void)[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable Tooltips everywhere
|
||||||
|
* @see https://getbootstrap.com/docs/5.0/components/tooltips/
|
||||||
|
*/
|
||||||
|
function initBootstrap(): void {
|
||||||
|
if (document !== null) {
|
||||||
|
const tooltips = Array.from(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||||
|
for (const tooltip of tooltips) {
|
||||||
|
new Tooltip(tooltip, { container: 'body', boundary: 'window' });
|
||||||
|
}
|
||||||
|
initMessageToasts();
|
||||||
|
initForms();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMasonry() {
|
||||||
|
if (document !== null) {
|
||||||
|
const grids = document.querySelectorAll('.masonry');
|
||||||
|
for (const grid of grids) {
|
||||||
|
new Masonry(grid, {
|
||||||
|
itemSelector: '.masonry-item',
|
||||||
|
percentPosition: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a slug from any input string.
|
||||||
|
* @param slug Original string.
|
||||||
|
* @param chars Maximum number of characters.
|
||||||
|
* @returns Slugified string.
|
||||||
|
*/
|
||||||
|
function slugify(slug: string, chars: number): string {
|
||||||
|
return slug
|
||||||
|
.replace(/[^\-\.\w\s]/g, '') // Remove unneeded chars
|
||||||
|
.replace(/^[\s\.]+|[\s\.]+$/g, '') // Trim leading/trailing spaces
|
||||||
|
.replace(/[\-\.\s]+/g, '-') // Convert spaces and decimals to hyphens
|
||||||
|
.toLowerCase() // Convert to lowercase
|
||||||
|
.substring(0, chars); // Trim to first chars chars
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a slug field exists, add event listeners to handle automatically generating its value.
|
||||||
|
*/
|
||||||
|
function bindReslug(): void {
|
||||||
|
const slugField = document.getElementById('id_slug') as HTMLInputElement;
|
||||||
|
const slugButton = document.getElementById('reslug') as HTMLButtonElement;
|
||||||
|
if (slugField === null || slugButton === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sourceId = slugField.getAttribute('slug-source');
|
||||||
|
const sourceField = document.getElementById(`id_${sourceId}`) as HTMLInputElement;
|
||||||
|
|
||||||
|
if (sourceField === null) {
|
||||||
|
console.error('Unable to find field for slug field.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugLengthAttr = slugField.getAttribute('maxlength');
|
||||||
|
let slugLength = 50;
|
||||||
|
|
||||||
|
if (slugLengthAttr) {
|
||||||
|
slugLength = Number(slugLengthAttr);
|
||||||
|
}
|
||||||
|
sourceField.addEventListener('blur', () => {
|
||||||
|
slugField.value = slugify(sourceField.value, slugLength);
|
||||||
|
});
|
||||||
|
slugButton.addEventListener('click', () => {
|
||||||
|
slugField.value = slugify(sourceField.value, slugLength);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState !== 'loading') {
|
||||||
|
initBootstrap();
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', initBootstrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const init of INITIALIZERS) {
|
||||||
|
init();
|
||||||
|
}
|
37
netbox/project-static/src/search.ts
Normal file
37
netbox/project-static/src/search.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
interface SearchFilterButton extends EventTarget {
|
||||||
|
dataset: { searchValue: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSearchButton(el: any): el is SearchFilterButton {
|
||||||
|
return el?.dataset?.searchValue ?? null !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initSearchBar() {
|
||||||
|
const dropdown = document.getElementById('object-type-selector');
|
||||||
|
const selectedValue = document.getElementById('selected-value') as HTMLSpanElement;
|
||||||
|
const selectedType = document.getElementById('search-obj-type') as HTMLInputElement;
|
||||||
|
let selected = '';
|
||||||
|
|
||||||
|
if (dropdown !== null) {
|
||||||
|
const buttons = dropdown.querySelectorAll('li > button.dropdown-item');
|
||||||
|
for (const button of buttons) {
|
||||||
|
if (button !== null) {
|
||||||
|
function handleClick(event: Event) {
|
||||||
|
if (isSearchButton(event.target)) {
|
||||||
|
const objectType = event.target.dataset.searchValue;
|
||||||
|
if (objectType !== '' && selected !== objectType) {
|
||||||
|
selected = objectType;
|
||||||
|
selectedValue.innerHTML = button.textContent ?? 'Error';
|
||||||
|
selectedType.value = objectType;
|
||||||
|
} else {
|
||||||
|
selected = '';
|
||||||
|
selectedType.innerHTML = 'All Objects';
|
||||||
|
selectedType.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button.addEventListener('click', handleClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
167
netbox/project-static/src/select-choices/api.ts
Normal file
167
netbox/project-static/src/select-choices/api.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import Choices from "choices.js";
|
||||||
|
import queryString from "query-string";
|
||||||
|
import { getApiData, isApiError } from "../util";
|
||||||
|
import { createToast } from "../toast";
|
||||||
|
|
||||||
|
import type { Choices as TChoices } from "choices.js";
|
||||||
|
|
||||||
|
interface CustomSelect extends HTMLSelectElement {
|
||||||
|
dataset: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCustomSelect(el: HTMLSelectElement): el is CustomSelect {
|
||||||
|
return typeof el?.dataset?.url === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a select element should be filtered by the value of another select element.
|
||||||
|
*
|
||||||
|
* Looks for the DOM attribute `data-query-param-<name of other field>`, which would look like:
|
||||||
|
* `["$<name>"]`
|
||||||
|
*
|
||||||
|
* If the attribute exists, parse out the raw value. In the above example, this would be `name`.
|
||||||
|
* @param element Element to scan
|
||||||
|
* @returns Attribute name, or null if it was not found.
|
||||||
|
*/
|
||||||
|
function getFilteredBy<T extends HTMLElement>(element: T): string[] {
|
||||||
|
const pattern = new RegExp(/\[|\]|"|\$/g);
|
||||||
|
const keys = Object.keys(element.dataset);
|
||||||
|
const filteredBy = [] as string[];
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key.includes("queryParam")) {
|
||||||
|
const value = element.dataset[key];
|
||||||
|
if (typeof value !== "undefined") {
|
||||||
|
const parsed = JSON.parse(value) as string | string[];
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
filteredBy.push(parsed[0].replaceAll(pattern, ""));
|
||||||
|
} else {
|
||||||
|
filteredBy.push(value.replaceAll(pattern, ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key === "url" && element.dataset.url?.includes(`{{`)) {
|
||||||
|
/**
|
||||||
|
* If the URL contains a Django/Jinja template variable tag we need to extract the variable
|
||||||
|
* name and consider this a field to monitor for changes.
|
||||||
|
*/
|
||||||
|
const value = element.dataset.url.match(/\{\{(.+)\}\}/);
|
||||||
|
if (value !== null) {
|
||||||
|
filteredBy.push(value[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filteredBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initApiSelect() {
|
||||||
|
const elements = document.querySelectorAll(
|
||||||
|
".netbox-select2-api"
|
||||||
|
) as NodeListOf<HTMLSelectElement>;
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
if (isCustomSelect(element)) {
|
||||||
|
let { url } = element.dataset;
|
||||||
|
|
||||||
|
const instance = new Choices(element, {
|
||||||
|
noChoicesText: "No Options Available",
|
||||||
|
itemSelectText: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve all objects for this object type.
|
||||||
|
*
|
||||||
|
* @param choiceUrl Optionally override the URL for filtering. If not set, the URL
|
||||||
|
* from the DOM attributes is used.
|
||||||
|
* @returns Data parsed into Choices.JS Choices.
|
||||||
|
*/
|
||||||
|
async function getChoices(
|
||||||
|
choiceUrl: string = url
|
||||||
|
): Promise<TChoices.Choice[]> {
|
||||||
|
if (choiceUrl.includes(`{{`)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return getApiData(choiceUrl).then((data) => {
|
||||||
|
if (isApiError(data)) {
|
||||||
|
const toast = createToast("danger", data.exception, data.error);
|
||||||
|
toast.show();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { results } = data;
|
||||||
|
const options = [] as TChoices.Choice[];
|
||||||
|
|
||||||
|
if (results.length !== 0) {
|
||||||
|
for (const result of results) {
|
||||||
|
const choice = {
|
||||||
|
value: result.id.toString(),
|
||||||
|
label: result.name,
|
||||||
|
} as TChoices.Choice;
|
||||||
|
options.push(choice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredBy = getFilteredBy(element);
|
||||||
|
|
||||||
|
if (filteredBy.length !== 0) {
|
||||||
|
for (const filter of filteredBy) {
|
||||||
|
// Find element with the `name` attribute matching this element's filtered-by attribute.
|
||||||
|
const groupElem = document.querySelector(
|
||||||
|
`[name=${filter}]`
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
|
||||||
|
if (groupElem !== null) {
|
||||||
|
/**
|
||||||
|
* When the group's selection changes, re-query the dependant element's options, but
|
||||||
|
* filtered to results matching the group's ID.
|
||||||
|
*
|
||||||
|
* @param event Group's DOM event.
|
||||||
|
*/
|
||||||
|
function handleEvent(event: Event) {
|
||||||
|
let filterUrl: string | undefined;
|
||||||
|
|
||||||
|
const target = event.target as HTMLSelectElement;
|
||||||
|
|
||||||
|
if (target.value) {
|
||||||
|
if (url.includes(`{{`)) {
|
||||||
|
/**
|
||||||
|
* If the URL contains a Django/Jinja template variable tag, we need to replace
|
||||||
|
* the tag with the event's value.
|
||||||
|
*/
|
||||||
|
url = url.replaceAll(/\{\{(.+)\}\}/g, target.value);
|
||||||
|
element.setAttribute("data-url", url);
|
||||||
|
}
|
||||||
|
let queryKey = filter;
|
||||||
|
if (filter?.includes("_group")) {
|
||||||
|
/**
|
||||||
|
* For example, a tenant's group relationship field is `group`, but the field
|
||||||
|
* name is `tenant_group`.
|
||||||
|
*/
|
||||||
|
queryKey = "group";
|
||||||
|
}
|
||||||
|
filterUrl = queryString.stringifyUrl({
|
||||||
|
url,
|
||||||
|
query: { [`${queryKey}_id`]: groupElem.value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.setChoices(
|
||||||
|
() => getChoices(filterUrl),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
groupElem.addEventListener("addItem", handleEvent);
|
||||||
|
groupElem.addEventListener("removeItem", handleEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.setChoices(() => getChoices());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2
netbox/project-static/src/select-choices/index.ts
Normal file
2
netbox/project-static/src/select-choices/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./api";
|
||||||
|
export * from "./static";
|
16
netbox/project-static/src/select-choices/static.ts
Normal file
16
netbox/project-static/src/select-choices/static.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import Choices from "choices.js";
|
||||||
|
|
||||||
|
export function initStaticSelect() {
|
||||||
|
const elements = document.querySelectorAll(
|
||||||
|
".netbox-select2-static"
|
||||||
|
) as NodeListOf<HTMLSelectElement>;
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
if (element !== null) {
|
||||||
|
new Choices(element, {
|
||||||
|
noChoicesText: "No Options Available",
|
||||||
|
itemSelectText: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
216
netbox/project-static/src/select/api.ts
Normal file
216
netbox/project-static/src/select/api.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import SlimSelect from 'slim-select';
|
||||||
|
import queryString from 'query-string';
|
||||||
|
import { getApiData, isApiError } from '../util';
|
||||||
|
import { createToast } from '../toast';
|
||||||
|
import { setOptionStyles, getFilteredBy } from './util';
|
||||||
|
|
||||||
|
import type { Option } from 'slim-select/dist/data';
|
||||||
|
|
||||||
|
type WithUrl = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WithExclude = {
|
||||||
|
queryParamExclude: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CustomSelect<T extends Record<string, string>> extends HTMLSelectElement {
|
||||||
|
dataset: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCustomSelect(el: HTMLSelectElement): el is CustomSelect<WithUrl> {
|
||||||
|
return typeof el?.dataset?.url === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExclusions(el: HTMLSelectElement): el is CustomSelect<WithExclude> {
|
||||||
|
return (
|
||||||
|
typeof el?.dataset?.queryParamExclude === 'string' && el?.dataset?.queryParamExclude !== ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLACEHOLDER = {
|
||||||
|
value: '',
|
||||||
|
text: '',
|
||||||
|
placeholder: true,
|
||||||
|
} as Option;
|
||||||
|
|
||||||
|
export function initApiSelect() {
|
||||||
|
const elements = document.querySelectorAll<HTMLSelectElement>('.netbox-select2-api');
|
||||||
|
for (const select of elements) {
|
||||||
|
// Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
|
||||||
|
// existing object. When we fetch options from the API later, we can set any of the options
|
||||||
|
// contained in this array to `selected`.
|
||||||
|
const selectOptions = Array.from(select.options)
|
||||||
|
.filter(option => option.value !== '')
|
||||||
|
.map(option => option.value);
|
||||||
|
|
||||||
|
const filteredBy = getFilteredBy(select);
|
||||||
|
const filterMap = new Map<string, string>();
|
||||||
|
|
||||||
|
if (isCustomSelect(select)) {
|
||||||
|
let { url } = select.dataset;
|
||||||
|
const displayField = select.getAttribute('display-field') ?? 'name';
|
||||||
|
|
||||||
|
// Set the placeholder text to the label value, if it exists.
|
||||||
|
let placeholder;
|
||||||
|
if (select.id) {
|
||||||
|
const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
|
||||||
|
|
||||||
|
if (label !== null) {
|
||||||
|
placeholder = `Select ${label.innerText.trim()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let disabledOptions = [] as string[];
|
||||||
|
if (hasExclusions(select)) {
|
||||||
|
disabledOptions = JSON.parse(select.dataset.queryParamExclude) as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new SlimSelect({
|
||||||
|
select,
|
||||||
|
allowDeselect: true,
|
||||||
|
deselectLabel: `<i class="bi bi-x-circle" style="color:currentColor;"></i>`,
|
||||||
|
placeholder,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset validity classes if the field was invalid.
|
||||||
|
instance.onChange = () => {
|
||||||
|
const element = instance.slim.container ?? null;
|
||||||
|
if (element !== null) {
|
||||||
|
if (element.classList.contains('is-invalid') || select.classList.contains('is-invalid')) {
|
||||||
|
select.classList.remove('is-invalid');
|
||||||
|
element.classList.remove('is-invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't copy classes from select element to SlimSelect instance.
|
||||||
|
for (const className of select.classList) {
|
||||||
|
instance.slim.container.classList.remove(className);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the underlying select element to the same size as the SlimSelect instance.
|
||||||
|
// This is primarily for built-in HTML form validation, which doesn't really work,
|
||||||
|
// but it also makes things seem cleaner in the DOM.
|
||||||
|
const { width, height } = instance.slim.container.getBoundingClientRect();
|
||||||
|
select.style.opacity = '0';
|
||||||
|
select.style.width = `${width}px`;
|
||||||
|
select.style.height = `${height}px`;
|
||||||
|
select.style.display = 'block';
|
||||||
|
select.style.position = 'absolute';
|
||||||
|
select.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve all objects for this object type.
|
||||||
|
*
|
||||||
|
* @param choiceUrl Optionally override the URL for filtering. If not set, the URL
|
||||||
|
* from the DOM attributes is used.
|
||||||
|
* @returns Data parsed into Choices.JS Choices.
|
||||||
|
*/
|
||||||
|
async function getChoices(choiceUrl: string = url): Promise<Option[]> {
|
||||||
|
if (choiceUrl.includes(`{{`)) {
|
||||||
|
return [PLACEHOLDER];
|
||||||
|
}
|
||||||
|
return getApiData(choiceUrl).then(data => {
|
||||||
|
if (isApiError(data)) {
|
||||||
|
const toast = createToast('danger', data.exception, data.error);
|
||||||
|
toast.show();
|
||||||
|
return [PLACEHOLDER];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = data;
|
||||||
|
const options = [PLACEHOLDER] as Option[];
|
||||||
|
|
||||||
|
if (results.length !== 0) {
|
||||||
|
for (const result of results) {
|
||||||
|
const data = {} as Record<string, string>;
|
||||||
|
const value = result.id.toString();
|
||||||
|
let style, selected;
|
||||||
|
for (const [k, v] of Object.entries(result)) {
|
||||||
|
if (
|
||||||
|
!['id', 'slug'].includes(k) &&
|
||||||
|
['string', 'number', 'boolean'].includes(typeof v)
|
||||||
|
) {
|
||||||
|
const key = k.replaceAll('_', '-');
|
||||||
|
data[key] = String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selectOptions.includes(value)) {
|
||||||
|
selected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const choice = {
|
||||||
|
value,
|
||||||
|
text: result[displayField],
|
||||||
|
data,
|
||||||
|
style,
|
||||||
|
selected,
|
||||||
|
} as Option;
|
||||||
|
|
||||||
|
options.push(choice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredBy.length !== 0) {
|
||||||
|
for (const filter of filteredBy) {
|
||||||
|
// Find element with the `name` attribute matching this element's filtered-by attribute.
|
||||||
|
const groupElem = document.querySelector(`[name=${filter}]`) as HTMLSelectElement;
|
||||||
|
|
||||||
|
if (groupElem !== null) {
|
||||||
|
// Add the group's value to the filtered-by map.
|
||||||
|
filterMap.set(filter, groupElem.value);
|
||||||
|
// If the URL contains a Django/Jinja template variable tag, we need to replace the tag
|
||||||
|
// with the event's value.
|
||||||
|
if (url.includes(`{{`)) {
|
||||||
|
url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), groupElem.value);
|
||||||
|
select.setAttribute('data-url', url);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* When the group's selection changes, re-query the dependant element's options, but
|
||||||
|
* filtered to results matching the group's ID.
|
||||||
|
*
|
||||||
|
* @param event Group's DOM event.
|
||||||
|
*/
|
||||||
|
function handleEvent(event: Event) {
|
||||||
|
let filterUrl: string | undefined;
|
||||||
|
|
||||||
|
const target = event.target as HTMLSelectElement;
|
||||||
|
|
||||||
|
if (target.value) {
|
||||||
|
let filterValue = filterMap.get(target.value);
|
||||||
|
if (url.includes(`{{`) && typeof filterValue !== 'undefined') {
|
||||||
|
// If the URL contains a Django/Jinja template variable tag, we need to replace
|
||||||
|
// the tag with the event's value.
|
||||||
|
url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), filterValue);
|
||||||
|
select.setAttribute('data-url', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let queryKey = filterValue;
|
||||||
|
if (filter?.includes('_group')) {
|
||||||
|
// For example, a tenant's group relationship field is `group`, but the field
|
||||||
|
// name is `tenant_group`.
|
||||||
|
queryKey = 'group';
|
||||||
|
}
|
||||||
|
filterUrl = queryString.stringifyUrl({
|
||||||
|
url,
|
||||||
|
query: { [`${queryKey}_id`]: groupElem.value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getChoices(filterUrl).then(data => instance.setData(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
groupElem.addEventListener('change', handleEvent);
|
||||||
|
groupElem.addEventListener('change', handleEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getChoices()
|
||||||
|
.then(data => instance.setData(data))
|
||||||
|
.finally(() => setOptionStyles(instance));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
82
netbox/project-static/src/select/color.ts
Normal file
82
netbox/project-static/src/select/color.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import SlimSelect from 'slim-select';
|
||||||
|
import { readableColor } from 'color2k';
|
||||||
|
import { getElements } from '../util';
|
||||||
|
|
||||||
|
import type { Option } from 'slim-select/dist/data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the option has a valid value (i.e., is not the placeholder).
|
||||||
|
*/
|
||||||
|
function canChangeColor(option: Option | HTMLOptionElement): boolean {
|
||||||
|
return typeof option.value === 'string' && option.value !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize color selection widget. Dynamically change the style of the select container to match
|
||||||
|
* the selected option.
|
||||||
|
*/
|
||||||
|
export function initColorSelect(): void {
|
||||||
|
for (const select of getElements<HTMLSelectElement>('select.netbox-select2-color-picker')) {
|
||||||
|
for (const option of select.options) {
|
||||||
|
if (canChangeColor(option)) {
|
||||||
|
// Get the background color from the option's value.
|
||||||
|
const bg = `#${option.value}`;
|
||||||
|
// Determine an accessible foreground color based on the background color.
|
||||||
|
const fg = readableColor(bg);
|
||||||
|
|
||||||
|
// Set the option's style attributes.
|
||||||
|
option.style.backgroundColor = bg;
|
||||||
|
option.style.color = fg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new SlimSelect({
|
||||||
|
select,
|
||||||
|
allowDeselect: true,
|
||||||
|
// Inherit the calculated color on the deselect icon.
|
||||||
|
deselectLabel: `<i class="bi bi-x-circle" style="color: currentColor;"></i>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Style the select container to match any pre-selectd options.
|
||||||
|
for (const option of instance.data.data) {
|
||||||
|
if ('selected' in option && option.selected) {
|
||||||
|
styleContainer(option);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't inherit the select element's classes.
|
||||||
|
for (const className of select.classList) {
|
||||||
|
instance.slim.container.classList.remove(className);
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleContainer(option: Option | HTMLOptionElement): void {
|
||||||
|
if (instance.slim.singleSelected !== null) {
|
||||||
|
if (canChangeColor(option)) {
|
||||||
|
// Get the background color from the selected option's value.
|
||||||
|
const bg = `#${option.value}`;
|
||||||
|
// Determine an accessible foreground color based on the background color.
|
||||||
|
const fg = readableColor(bg);
|
||||||
|
|
||||||
|
// Set the container's style attributes.
|
||||||
|
instance.slim.singleSelected.container.style.backgroundColor = bg;
|
||||||
|
instance.slim.singleSelected.container.style.color = fg;
|
||||||
|
|
||||||
|
// Find this element's label.
|
||||||
|
const label = document.querySelector<HTMLLabelElement>(`label[for=${select.id}]`);
|
||||||
|
|
||||||
|
if (label !== null) {
|
||||||
|
// Set the field's label color to match (Bootstrap sets the opacity to 0.65 as well).
|
||||||
|
label.style.color = fg;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the color cannot be set (i.e., the placeholder), remove any inline styles.
|
||||||
|
instance.slim.singleSelected.container.removeAttribute('style');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the SlimSelect container's style based on the selected option.
|
||||||
|
instance.onChange = styleContainer;
|
||||||
|
}
|
||||||
|
}
|
3
netbox/project-static/src/select/index.ts
Normal file
3
netbox/project-static/src/select/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './api';
|
||||||
|
export * from './static';
|
||||||
|
export * from './color';
|
23
netbox/project-static/src/select/static.ts
Normal file
23
netbox/project-static/src/select/static.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import SlimSelect from 'slim-select';
|
||||||
|
|
||||||
|
export function initStaticSelect() {
|
||||||
|
const elements = document.querySelectorAll(
|
||||||
|
'.netbox-select2-static',
|
||||||
|
) as NodeListOf<HTMLSelectElement>;
|
||||||
|
|
||||||
|
for (const select of elements) {
|
||||||
|
if (select !== null) {
|
||||||
|
const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
|
||||||
|
let placeholder;
|
||||||
|
if (label !== null) {
|
||||||
|
placeholder = `Select ${label.innerText.trim()}`;
|
||||||
|
}
|
||||||
|
new SlimSelect({
|
||||||
|
select,
|
||||||
|
allowDeselect: true,
|
||||||
|
deselectLabel: `<i class="bi bi-x-circle"></i>`,
|
||||||
|
placeholder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
99
netbox/project-static/src/select/util.ts
Normal file
99
netbox/project-static/src/select/util.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { readableColor } from 'color2k';
|
||||||
|
|
||||||
|
import type SlimSelect from 'slim-select';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add scoped style elements specific to each SlimSelect option, if the color property exists.
|
||||||
|
* As of this writing, this attribute only exist on Tags. The color property is used as the
|
||||||
|
* background color, and a foreground color is detected based on the luminosity of the background
|
||||||
|
* color.
|
||||||
|
*
|
||||||
|
* @param instance SlimSelect instance with options already set.
|
||||||
|
*/
|
||||||
|
export function setOptionStyles(instance: SlimSelect): void {
|
||||||
|
const options = instance.data.data;
|
||||||
|
for (const option of options) {
|
||||||
|
// Only create style elements for options that contain a color attribute.
|
||||||
|
if (
|
||||||
|
'data' in option &&
|
||||||
|
'id' in option &&
|
||||||
|
typeof option.data !== 'undefined' &&
|
||||||
|
typeof option.id !== 'undefined' &&
|
||||||
|
'color' in option.data
|
||||||
|
) {
|
||||||
|
const id = option.id as string;
|
||||||
|
const data = option.data as { color: string };
|
||||||
|
|
||||||
|
// Create the style element.
|
||||||
|
const style = document.createElement('style');
|
||||||
|
|
||||||
|
// Append hash to color to make it a valid hex color.
|
||||||
|
const bg = `#${data.color}`;
|
||||||
|
// Detect the foreground color.
|
||||||
|
const fg = readableColor(bg);
|
||||||
|
|
||||||
|
// Add a unique identifier to the style element.
|
||||||
|
style.dataset.netbox = id;
|
||||||
|
|
||||||
|
// Scope the CSS to apply both the list item and the selected item.
|
||||||
|
style.innerHTML = `
|
||||||
|
div.ss-values div.ss-value[data-id="${id}"],
|
||||||
|
div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
|
||||||
|
{
|
||||||
|
background-color: ${bg} !important;
|
||||||
|
color: ${fg} !important;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
.replaceAll('\n', '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Add the style element to the DOM.
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a select element should be filtered by the value of another select element.
|
||||||
|
*
|
||||||
|
* Looks for the DOM attribute `data-query-param-<name of other field>`, which would look like:
|
||||||
|
* `["$<name>"]`
|
||||||
|
*
|
||||||
|
* If the attribute exists, parse out the raw value. In the above example, this would be `name`.
|
||||||
|
*
|
||||||
|
* @param element Element to scan
|
||||||
|
* @returns Attribute name, or null if it was not found.
|
||||||
|
*/
|
||||||
|
export function getFilteredBy<T extends HTMLElement>(element: T): string[] {
|
||||||
|
const pattern = new RegExp(/\[|\]|"|\$/g);
|
||||||
|
const keys = Object.keys(element.dataset);
|
||||||
|
const filteredBy = [] as string[];
|
||||||
|
|
||||||
|
// Process the URL attribute in a separate loop so that it comes first.
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key === 'url' && element.dataset.url?.includes(`{{`)) {
|
||||||
|
/**
|
||||||
|
* If the URL contains a Django/Jinja template variable tag we need to extract the variable
|
||||||
|
* name and consider this a field to monitor for changes.
|
||||||
|
*/
|
||||||
|
const value = element.dataset.url.match(/\{\{(.+)\}\}/);
|
||||||
|
if (value !== null) {
|
||||||
|
filteredBy.push(value[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key.includes('queryParam') && key !== 'queryParamExclude') {
|
||||||
|
const value = element.dataset[key];
|
||||||
|
if (typeof value !== 'undefined') {
|
||||||
|
const parsed = JSON.parse(value) as string | string[];
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
filteredBy.push(parsed[0].replaceAll(pattern, ''));
|
||||||
|
} else {
|
||||||
|
filteredBy.push(value.replaceAll(pattern, ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filteredBy;
|
||||||
|
}
|
86
netbox/project-static/src/toast.ts
Normal file
86
netbox/project-static/src/toast.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { Toast } from 'bootstrap';
|
||||||
|
|
||||||
|
type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
|
||||||
|
|
||||||
|
export function createToast(
|
||||||
|
level: ToastLevel,
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
extra?: string,
|
||||||
|
): Toast {
|
||||||
|
let iconName = 'bi-exclamation-triangle-fill';
|
||||||
|
switch (level) {
|
||||||
|
case 'warning':
|
||||||
|
iconName = 'bi-exclamation-triangle-fill';
|
||||||
|
case 'success':
|
||||||
|
iconName = 'bi-check-circle-fill';
|
||||||
|
case 'info':
|
||||||
|
iconName = 'bi-info-circle-fill';
|
||||||
|
case 'danger':
|
||||||
|
iconName = 'bi-exclamation-triangle-fill';
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.setAttribute('class', 'toast-container position-fixed bottom-0 end-0 m-3');
|
||||||
|
|
||||||
|
const main = document.createElement('div');
|
||||||
|
main.setAttribute('class', `toast bg-${level}`);
|
||||||
|
main.setAttribute('role', 'alert');
|
||||||
|
main.setAttribute('aria-live', 'assertive');
|
||||||
|
main.setAttribute('aria-atomic', 'true');
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.setAttribute('class', `toast-header bg-${level} text-body`);
|
||||||
|
|
||||||
|
const icon = document.createElement('i');
|
||||||
|
icon.setAttribute('class', `bi ${iconName}`);
|
||||||
|
|
||||||
|
const titleElement = document.createElement('strong');
|
||||||
|
titleElement.setAttribute('class', 'me-auto ms-1');
|
||||||
|
titleElement.innerText = title;
|
||||||
|
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.setAttribute('type', 'button');
|
||||||
|
button.setAttribute('class', 'btn-close');
|
||||||
|
button.setAttribute('data-bs-dismiss', 'toast');
|
||||||
|
button.setAttribute('aria-label', 'Close');
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.setAttribute('class', 'toast-body');
|
||||||
|
|
||||||
|
header.appendChild(icon);
|
||||||
|
header.appendChild(titleElement);
|
||||||
|
|
||||||
|
if (typeof extra !== 'undefined') {
|
||||||
|
const extraElement = document.createElement('small');
|
||||||
|
extraElement.setAttribute('class', 'text-muted');
|
||||||
|
header.appendChild(extraElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
header.appendChild(button);
|
||||||
|
|
||||||
|
body.innerText = message.trim();
|
||||||
|
|
||||||
|
main.appendChild(header);
|
||||||
|
main.appendChild(body);
|
||||||
|
container.appendChild(main);
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const toast = new Toast(main);
|
||||||
|
return toast;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find any active messages from django.contrib.messages and show them in a toast.
|
||||||
|
*/
|
||||||
|
export function initMessageToasts(): void {
|
||||||
|
const elements = document.querySelectorAll<HTMLDivElement>(
|
||||||
|
'body > div#django-messages > div.django-message.toast',
|
||||||
|
);
|
||||||
|
for (const element of elements) {
|
||||||
|
if (element !== null) {
|
||||||
|
const toast = new Toast(element);
|
||||||
|
toast.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
71
netbox/project-static/src/util.ts
Normal file
71
netbox/project-static/src/util.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import Cookie from 'cookie';
|
||||||
|
|
||||||
|
export function isApiError(data: Record<string, unknown>): data is APIError {
|
||||||
|
return 'error' in data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the CSRF token from cookie storage.
|
||||||
|
*/
|
||||||
|
export function getCsrfToken(): string {
|
||||||
|
const { csrftoken: csrfToken } = Cookie.parse(document.cookie);
|
||||||
|
if (typeof csrfToken === 'undefined') {
|
||||||
|
throw new Error('Invalid or missing CSRF token');
|
||||||
|
}
|
||||||
|
return csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch data from the NetBox API (authenticated).
|
||||||
|
* @param url API endpoint
|
||||||
|
*/
|
||||||
|
export async function getApiData<T extends APIObjectBase>(
|
||||||
|
url: string,
|
||||||
|
): Promise<APIAnswer<T> | APIError> {
|
||||||
|
const token = getCsrfToken();
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'X-CSRFToken': token },
|
||||||
|
});
|
||||||
|
const json = (await res.json()) as APIAnswer<T> | APIError;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElements<K extends keyof SVGElementTagNameMap>(
|
||||||
|
key: K,
|
||||||
|
): Generator<SVGElementTagNameMap[K]>;
|
||||||
|
export function getElements<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
key: K,
|
||||||
|
): Generator<HTMLElementTagNameMap[K]>;
|
||||||
|
export function getElements<E extends Element>(key: string): Generator<E>;
|
||||||
|
export function* getElements(
|
||||||
|
key: string | keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap,
|
||||||
|
) {
|
||||||
|
for (const element of document.querySelectorAll(key)) {
|
||||||
|
if (element !== null) {
|
||||||
|
yield element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* room to see said element.
|
||||||
|
*
|
||||||
|
* @param element Element to scroll to
|
||||||
|
* @param offset Y Offset. 0 by default, to take into account the NetBox header.
|
||||||
|
*/
|
||||||
|
export function scrollTo(element: Element, offset: number = 0): void {
|
||||||
|
let yOffset = offset;
|
||||||
|
const title = document.getElementById('content-title') as Nullable<HTMLDivElement>;
|
||||||
|
if (title !== null) {
|
||||||
|
// If the #content-title element exists, add it to the offset.
|
||||||
|
yOffset += title.getBoundingClientRect().bottom;
|
||||||
|
}
|
||||||
|
// Calculate the scrollTo target.
|
||||||
|
const top = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
|
||||||
|
// Scroll to the calculated location.
|
||||||
|
window.scrollTo({ top, behavior: 'smooth' });
|
||||||
|
return;
|
||||||
|
}
|
Reference in New Issue
Block a user