From 912cd220cc5c7977fea270a89c6e23abab546575 Mon Sep 17 00:00:00 2001 From: checktheroads Date: Sat, 13 Mar 2021 02:31:57 -0700 Subject: [PATCH] add javascript --- netbox/project-static/src/dateSelector.ts | 18 ++ netbox/project-static/src/forms.ts | 97 ++++++++ netbox/project-static/src/global.d.ts | 38 +++ netbox/project-static/src/index.ts | 13 ++ netbox/project-static/src/netbox.ts | 101 ++++++++ netbox/project-static/src/search.ts | 37 +++ .../project-static/src/select-choices/api.ts | 167 ++++++++++++++ .../src/select-choices/index.ts | 2 + .../src/select-choices/static.ts | 16 ++ netbox/project-static/src/select/api.ts | 216 ++++++++++++++++++ netbox/project-static/src/select/color.ts | 82 +++++++ netbox/project-static/src/select/index.ts | 3 + netbox/project-static/src/select/static.ts | 23 ++ netbox/project-static/src/select/util.ts | 99 ++++++++ netbox/project-static/src/toast.ts | 86 +++++++ netbox/project-static/src/util.ts | 71 ++++++ 16 files changed, 1069 insertions(+) create mode 100644 netbox/project-static/src/dateSelector.ts create mode 100644 netbox/project-static/src/forms.ts create mode 100644 netbox/project-static/src/global.d.ts create mode 100644 netbox/project-static/src/index.ts create mode 100644 netbox/project-static/src/netbox.ts create mode 100644 netbox/project-static/src/search.ts create mode 100644 netbox/project-static/src/select-choices/api.ts create mode 100644 netbox/project-static/src/select-choices/index.ts create mode 100644 netbox/project-static/src/select-choices/static.ts create mode 100644 netbox/project-static/src/select/api.ts create mode 100644 netbox/project-static/src/select/color.ts create mode 100644 netbox/project-static/src/select/index.ts create mode 100644 netbox/project-static/src/select/static.ts create mode 100644 netbox/project-static/src/select/util.ts create mode 100644 netbox/project-static/src/toast.ts create mode 100644 netbox/project-static/src/util.ts diff --git a/netbox/project-static/src/dateSelector.ts b/netbox/project-static/src/dateSelector.ts new file mode 100644 index 000000000..a4ace0053 --- /dev/null +++ b/netbox/project-static/src/dateSelector.ts @@ -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, + }); +} diff --git a/netbox/project-static/src/forms.ts b/netbox/project-static/src/forms.ts new file mode 100644 index 000000000..ee9621229 --- /dev/null +++ b/netbox/project-static/src/forms.ts @@ -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('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; + 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(); + + 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); + } + } +} diff --git a/netbox/project-static/src/global.d.ts b/netbox/project-static/src/global.d.ts new file mode 100644 index 000000000..2f03f7003 --- /dev/null +++ b/netbox/project-static/src/global.d.ts @@ -0,0 +1,38 @@ +type Nullable = T | null; + +type APIAnswer = { + count: number; + next: Nullable; + previous: Nullable; + 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; +} + +declare const messages: string[]; + +type FormControls = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; diff --git a/netbox/project-static/src/index.ts b/netbox/project-static/src/index.ts new file mode 100644 index 000000000..a46f47efd --- /dev/null +++ b/netbox/project-static/src/index.ts @@ -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'); diff --git a/netbox/project-static/src/netbox.ts b/netbox/project-static/src/netbox.ts new file mode 100644 index 000000000..2e9c086cd --- /dev/null +++ b/netbox/project-static/src/netbox.ts @@ -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(); +} diff --git a/netbox/project-static/src/search.ts b/netbox/project-static/src/search.ts new file mode 100644 index 000000000..20cf0c99a --- /dev/null +++ b/netbox/project-static/src/search.ts @@ -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); + } + } + } +} diff --git a/netbox/project-static/src/select-choices/api.ts b/netbox/project-static/src/select-choices/api.ts new file mode 100644 index 000000000..b1864f739 --- /dev/null +++ b/netbox/project-static/src/select-choices/api.ts @@ -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-`, which would look like: + * `["$"]` + * + * 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(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; + + 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 { + 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()); + } + } +} diff --git a/netbox/project-static/src/select-choices/index.ts b/netbox/project-static/src/select-choices/index.ts new file mode 100644 index 000000000..209b690c2 --- /dev/null +++ b/netbox/project-static/src/select-choices/index.ts @@ -0,0 +1,2 @@ +export * from "./api"; +export * from "./static"; diff --git a/netbox/project-static/src/select-choices/static.ts b/netbox/project-static/src/select-choices/static.ts new file mode 100644 index 000000000..2500d6d6a --- /dev/null +++ b/netbox/project-static/src/select-choices/static.ts @@ -0,0 +1,16 @@ +import Choices from "choices.js"; + +export function initStaticSelect() { + const elements = document.querySelectorAll( + ".netbox-select2-static" + ) as NodeListOf; + + for (const element of elements) { + if (element !== null) { + new Choices(element, { + noChoicesText: "No Options Available", + itemSelectText: "", + }); + } + } +} diff --git a/netbox/project-static/src/select/api.ts b/netbox/project-static/src/select/api.ts new file mode 100644 index 000000000..88e79bed1 --- /dev/null +++ b/netbox/project-static/src/select/api.ts @@ -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> extends HTMLSelectElement { + dataset: T; +} + +function isCustomSelect(el: HTMLSelectElement): el is CustomSelect { + return typeof el?.dataset?.url === 'string'; +} + +function hasExclusions(el: HTMLSelectElement): el is CustomSelect { + return ( + typeof el?.dataset?.queryParamExclude === 'string' && el?.dataset?.queryParamExclude !== '' + ); +} + +const PLACEHOLDER = { + value: '', + text: '', + placeholder: true, +} as Option; + +export function initApiSelect() { + const elements = document.querySelectorAll('.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(); + + 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: ``, + 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 { + 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; + 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)); + } + } +} diff --git a/netbox/project-static/src/select/color.ts b/netbox/project-static/src/select/color.ts new file mode 100644 index 000000000..bde01406c --- /dev/null +++ b/netbox/project-static/src/select/color.ts @@ -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('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: ``, + }); + + // 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(`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; + } +} diff --git a/netbox/project-static/src/select/index.ts b/netbox/project-static/src/select/index.ts new file mode 100644 index 000000000..a732be004 --- /dev/null +++ b/netbox/project-static/src/select/index.ts @@ -0,0 +1,3 @@ +export * from './api'; +export * from './static'; +export * from './color'; diff --git a/netbox/project-static/src/select/static.ts b/netbox/project-static/src/select/static.ts new file mode 100644 index 000000000..975a7381e --- /dev/null +++ b/netbox/project-static/src/select/static.ts @@ -0,0 +1,23 @@ +import SlimSelect from 'slim-select'; + +export function initStaticSelect() { + const elements = document.querySelectorAll( + '.netbox-select2-static', + ) as NodeListOf; + + 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: ``, + placeholder, + }); + } + } +} diff --git a/netbox/project-static/src/select/util.ts b/netbox/project-static/src/select/util.ts new file mode 100644 index 000000000..001131c11 --- /dev/null +++ b/netbox/project-static/src/select/util.ts @@ -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-`, which would look like: + * `["$"]` + * + * 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(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; +} diff --git a/netbox/project-static/src/toast.ts b/netbox/project-static/src/toast.ts new file mode 100644 index 000000000..34485137d --- /dev/null +++ b/netbox/project-static/src/toast.ts @@ -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( + 'body > div#django-messages > div.django-message.toast', + ); + for (const element of elements) { + if (element !== null) { + const toast = new Toast(element); + toast.show(); + } + } +} diff --git a/netbox/project-static/src/util.ts b/netbox/project-static/src/util.ts new file mode 100644 index 000000000..1770f9de5 --- /dev/null +++ b/netbox/project-static/src/util.ts @@ -0,0 +1,71 @@ +import Cookie from 'cookie'; + +export function isApiError(data: Record): 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( + url: string, +): Promise | APIError> { + const token = getCsrfToken(); + const res = await fetch(url, { + method: 'GET', + headers: { 'X-CSRFToken': token }, + }); + const json = (await res.json()) as APIAnswer | APIError; + return json; +} + +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, +) { + 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; + 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; +}