type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; type ReqData = URLSearchParams | Dict | undefined | unknown; type SelectedOption = { name: string; options: string[] }; declare global { interface Window { CSRF_TOKEN: any; } } /** * Infer valid HTMLElement props based on element name. */ type InferredProps< // Element name. T extends keyof HTMLElementTagNameMap, // Element type. E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T], > = Partial>; export function isApiError(data: Record): data is APIError { return 'error' in data && 'exception' in data; } export function hasError( data: Record, ): data is E { return 'error' in data; } export function hasMore(data: APIAnswer): data is APIAnswerWithNext { return typeof data.next === 'string'; } /** * Type guard to determine if a value is not null, undefined, or empty. */ export function isTruthy(value: V): value is NonNullable { const badStrings = ['', 'null', 'undefined']; if (Array.isArray(value)) { return value.length > 0; } else if (typeof value === 'string' && !badStrings.includes(value)) { return true; } else if (typeof value === 'number') { return true; } else if (typeof value === 'boolean') { return true; } else if (typeof value === 'object' && value !== null) { return true; } return false; } /** * Type guard to determine if all elements of an array are not null or undefined. * * @example * ```js * const elements = [document.getElementById("element1"), document.getElementById("element2")]; * if (all(elements)) { * const [element1, element2] = elements; * // element1 and element2 are now of type HTMLElement, not Nullable. * } * ``` */ export function all(values: T[]): values is NonNullable[] { return values.every(value => typeof value !== 'undefined' && value !== null); } /** * Deselect all selected options and reset the field value of a select element. * * @example * ```js * const select = document.querySelectorAll("select.example"); * select.value = "test"; * console.log(select.value); * // test * resetSelect(select); * console.log(select.value); * // '' * ``` */ export function resetSelect(select: S): void { for (const option of select.options) { if (option.selected) { option.selected = false; } } select.value = ''; } /** * Type guard to determine if a value is an `Element`. */ export function isElement(obj: Element | null | undefined): obj is Element { return typeof obj !== null && typeof obj !== 'undefined'; } export async function apiRequest( url: string, method: Method, data?: D, ): Promise> { const token = window.CSRF_TOKEN; const headers = new Headers({ 'X-CSRFToken': token }); let body; if (typeof data !== 'undefined') { body = JSON.stringify(data); headers.set('content-type', 'application/json'); } const res = await fetch(url, { method, body, headers, credentials: 'same-origin' }); const contentType = res.headers.get('Content-Type'); if (typeof contentType === 'string' && contentType.includes('text')) { const error = await res.text(); return { error } as ErrorBase; } const json = (await res.json()) as R | APIError; if (!res.ok && Array.isArray(json)) { const error = json.join('\n'); return { error } as ErrorBase; } else if (!res.ok && 'detail' in json) { return { error: json.detail } as ErrorBase; } return json; } export async function apiPatch( url: string, data: D, ): Promise> { return await apiRequest(url, 'PATCH', data); } export async function apiGetBase(url: string): Promise> { return await apiRequest(url, 'GET'); } export async function apiPostForm( url: string, data: D, ): Promise> { const body = new URLSearchParams(); for (const [k, v] of Object.entries(data)) { body.append(k, String(v)); } return await apiRequest(url, 'POST', body); } /** * Fetch data from the NetBox API (authenticated). * @param url API endpoint */ export async function getApiData( url: string, ): Promise | ErrorBase | APIError> { return await apiGetBase>(url); } export function getElements( ...key: K[] ): Generator; export function getElements( ...key: K[] ): Generator; export function getElements(...key: string[]): Generator; export function* getElements( ...key: (string | keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap)[] ): Generator { for (const query of key) { for (const element of document.querySelectorAll(query)) { if (element !== null) { yield element; } } } } export function getElement(id: string): Nullable { return document.getElementById(id) as Nullable; } export function removeElements(...selectors: string[]): void { for (const element of getElements(...selectors)) { element.remove(); } } export function elementWidth(element: Nullable): number { let width = 0; if (element !== null) { const style = getComputedStyle(element); const pre = style.width.replace('px', ''); width = parseFloat(pre); } return width; } /** * scrollTo() wrapper that calculates a Y offset relative to `element`, but also factors in an * offset relative to div#content-title. This ensures we scroll to the element, but leave enough * 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; 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; } /** * Iterate through a select element's options and return an array of options that are selected. * * @param base Select element. * @param selector Optionally specify a selector. 'select' by default. * @returns Array of selected options. */ export function getSelectedOptions( base: E, selector: string = 'select', ): SelectedOption[] { let selected = [] as SelectedOption[]; for (const element of base.querySelectorAll(selector)) { if (element !== null) { const select = { name: element.name, options: [] } as SelectedOption; for (const option of element.options) { if (option.selected) { select.options.push(option.value); } } selected = [...selected, select]; } } return selected; } /** * Get data that can only be accessed via Django context, and is thus already rendered in the HTML * template. * * @see Templates requiring Django context data have a `{% block data %}` block. * * @param key Property name, which must exist on the HTML element. If not already prefixed with * `data-`, `data-` will be prepended to the property. * @returns Value if it exists, `null` if not. */ export function getNetboxData(key: string): string | null { if (!key.startsWith('data-')) { key = `data-${key}`; } for (const element of getElements('body > div#netbox-data > *')) { const value = element.getAttribute(key); if (isTruthy(value)) { return value; } } return null; } /** * Toggle visibility of an element. */ export function toggleVisibility( element: E | null, action?: 'show' | 'hide', ): void { if (element !== null) { if (typeof action === 'undefined') { // No action is passed, so we should toggle the existing state. const current = window.getComputedStyle(element).display; if (current === 'none') { element.style.display = ''; } else { element.style.display = 'none'; } } else { if (action === 'show') { element.style.display = ''; } else { element.style.display = 'none'; } } } } /** * Toggle visibility of card loader. */ export function toggleLoader(action: 'show' | 'hide'): void { for (const element of getElements('div.card-overlay')) { toggleVisibility(element, action); } } /** * Get the value of every cell in a table. * @param table Table Element */ export function* getRowValues(table: HTMLTableRowElement): Generator { for (const element of table.querySelectorAll('td')) { if (element !== null) { if (isTruthy(element.innerText) && element.innerText !== '—') { yield replaceAll(element.innerText, '[\n\r]', '').trim(); } } } } /** * Recurse upward through an element's siblings until an element matching the query is found. * * @param base Base Element * @param query CSS Query * @param boundary Optionally specify a CSS Query which, when matched, recursion will cease. */ export function findFirstAdjacent( base: B, query: string, boundary?: string, ): Nullable { function atBoundary(element: E): boolean { if (typeof boundary === 'string' && element !== null) { if (element.matches(boundary)) { return true; } } return false; } function match

(parent: P): Nullable { if (parent !== null && parent.parentElement !== null && !atBoundary(parent)) { for (const child of parent.parentElement.querySelectorAll(query)) { if (child !== null) { return child; } } return match(parent.parentElement.parentElement); } return null; } return match(base); } /** * Helper for creating HTML elements. * * @param tag HTML element type. * @param properties Properties/attributes to apply to the element. * @param classes CSS classes to apply to the element. * @param children Child elements. */ export function createElement< // Element name. T extends keyof HTMLElementTagNameMap, // Element props. P extends InferredProps, // Child element type. C extends HTMLElement = HTMLElement, >( tag: T, properties: P | null, classes: Nullable = null, children: C[] = [], ): HTMLElementTagNameMap[T] { // Create the base element. const element = document.createElement(tag); if (properties !== null) { for (const k of Object.keys(properties)) { // Add each property to the element. const key = k as keyof InferredProps; const value = properties[key] as NonNullable; if (key in element) { element[key] = value; } } } // Add each CSS class to the element's class list. if (classes !== null && classes.length > 0) { element.classList.add(...classes); } for (const child of children) { // Add each child element to the base element. element.appendChild(child); } return element as HTMLElementTagNameMap[T]; } /** * Convert Celsius to Fahrenheit, for NAPALM temperature sensors. * * @param celsius Degrees in Celsius. * @returns Degrees in Fahrenheit. */ export function cToF(celsius: number): number { return Math.round((celsius * (9 / 5) + 32 + Number.EPSILON) * 10) / 10; } /** * Deduplicate an array of objects based on the value of a property. * * @example * ```js * const withDups = [{id: 1, name: 'One'}, {id: 2, name: 'Two'}, {id: 1, name: 'Other One'}]; * const withoutDups = uniqueByProperty(withDups, 'id'); * console.log(withoutDups); * // [{id: 1, name: 'One'}, {id: 2, name: 'Two'}] * ``` * @param arr Array of objects to deduplicate. * @param prop Object property to use as a unique key. * @returns Deduplicated array. */ export function uniqueByProperty(arr: T[], prop: P): T[] { const baseMap = new Map(); for (const item of arr) { const value = item[prop]; if (!baseMap.has(value)) { baseMap.set(value, item); } } return Array.from(baseMap.values()); } /** * Replace all occurrences of a pattern with a replacement string. * * This is a browser-compatibility-focused drop-in replacement for `String.prototype.replaceAll()`, * introduced in ES2021. * * @param input string to be processed. * @param pattern regex pattern string or RegExp object to search for. * @param replacement replacement substring with which `pattern` matches will be replaced. * @returns processed version of `input`. */ export function replaceAll(input: string, pattern: string | RegExp, replacement: string): string { // Ensure input is a string. if (typeof input !== 'string') { throw new TypeError("replaceAll 'input' argument must be a string"); } // Ensure pattern is a string or RegExp. if (typeof pattern !== 'string' && !(pattern instanceof RegExp)) { throw new TypeError("replaceAll 'pattern' argument must be a string or RegExp instance"); } // Ensure replacement is able to be stringified. switch (typeof replacement) { case 'boolean': replacement = String(replacement); break; case 'number': replacement = String(replacement); break; case 'string': break; default: throw new TypeError("replaceAll 'replacement' argument must be stringifyable"); } if (pattern instanceof RegExp) { // Add global flag to existing RegExp object and deduplicate const flags = Array.from(new Set([...pattern.flags.split(''), 'g'])).join(''); pattern = new RegExp(pattern.source, flags); } else { // Create a RegExp object with the global flag set. pattern = new RegExp(pattern, 'g'); } return input.replace(pattern, replacement); }