1
0
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:
checktheroads
2021-03-13 02:31:57 -07:00
parent 3b120c0372
commit 912cd220cc
16 changed files with 1069 additions and 0 deletions

View 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,
});
}

View 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
View 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;

View 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');

View 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();
}

View 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);
}
}
}
}

View 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());
}
}
}

View File

@ -0,0 +1,2 @@
export * from "./api";
export * from "./static";

View 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: "",
});
}
}
}

View 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));
}
}
}

View 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;
}
}

View File

@ -0,0 +1,3 @@
export * from './api';
export * from './static';
export * from './color';

View 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,
});
}
}
}

View 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;
}

View 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();
}
}
}

View 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;
}