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