diff --git a/netbox/project-static/dist/config.js b/netbox/project-static/dist/config.js index a1a0c35c6..8524f86b9 100644 Binary files a/netbox/project-static/dist/config.js and b/netbox/project-static/dist/config.js differ diff --git a/netbox/project-static/dist/config.js.map b/netbox/project-static/dist/config.js.map index 969e1bb2e..758773542 100644 Binary files a/netbox/project-static/dist/config.js.map and b/netbox/project-static/dist/config.js.map differ diff --git a/netbox/project-static/dist/jobs.js b/netbox/project-static/dist/jobs.js index c463bc18b..2ccb85094 100644 Binary files a/netbox/project-static/dist/jobs.js and b/netbox/project-static/dist/jobs.js differ diff --git a/netbox/project-static/dist/jobs.js.map b/netbox/project-static/dist/jobs.js.map index d4046b543..049c31c58 100644 Binary files a/netbox/project-static/dist/jobs.js.map and b/netbox/project-static/dist/jobs.js.map differ diff --git a/netbox/project-static/dist/lldp.js b/netbox/project-static/dist/lldp.js index 55e0f651d..0b4a0d2ac 100644 Binary files a/netbox/project-static/dist/lldp.js and b/netbox/project-static/dist/lldp.js differ diff --git a/netbox/project-static/dist/lldp.js.map b/netbox/project-static/dist/lldp.js.map index 6f2bcd677..ad297dd09 100644 Binary files a/netbox/project-static/dist/lldp.js.map and b/netbox/project-static/dist/lldp.js.map differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 08ffda791..c727cb79d 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index e4d567239..2b638a87f 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/toast.ts b/netbox/project-static/src/bs.ts similarity index 53% rename from netbox/project-static/src/toast.ts rename to netbox/project-static/src/bs.ts index 34485137d..ffb23d10d 100644 --- a/netbox/project-static/src/toast.ts +++ b/netbox/project-static/src/bs.ts @@ -1,7 +1,33 @@ -import { Toast } from 'bootstrap'; +import { Modal, Tab, Toast, Tooltip } from 'bootstrap'; +import Masonry from 'masonry-layout'; +import { getElements } from './util'; type ToastLevel = 'danger' | 'warning' | 'success' | 'info'; +/** + * Initialize masonry-layout for homepage (or any other masonry layout cards). + */ +function initMasonry(): void { + for (const grid of getElements('.masonry')) { + new Masonry(grid, { + itemSelector: '.masonry-item', + percentPosition: true, + }); + } +} + +function initTooltips() { + for (const tooltip of getElements('[data-bs-toggle="tooltip"]')) { + new Tooltip(tooltip, { container: 'body', boundary: 'window' }); + } +} + +function initModals() { + for (const modal of getElements('[data-bs-toggle="modal"]')) { + new Modal(modal); + } +} + export function createToast( level: ToastLevel, title: string, @@ -71,16 +97,33 @@ export function createToast( } /** - * Find any active messages from django.contrib.messages and show them in a toast. + * Open the tab specified in the URL. For example, /dcim/device-types/1/#tab_frontports will + * change the open tab to the Front Ports tab. */ -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(); +function initTabs() { + const { hash } = location; + if (hash && hash.match(/^\#tab_.+$/)) { + // The tab element will have a data-bs-target attribute with a value of the object type for + // the corresponding tab. Once we drop the `tab_` prefix, the hash will match the target + // element's data-bs-target value. For example, `#tab_frontports` becomes `#frontports`. + const target = hash.replace('tab_', ''); + for (const element of getElements(`ul.nav.nav-tabs .nav-link[data-bs-target="${target}"]`)) { + // Instantiate a Bootstrap tab instance. + // See https://getbootstrap.com/docs/5.0/components/navs-tabs/#javascript-behavior + const tab = new Tab(element); + // Show the tab. + tab.show(); } } } + +/** + * Enable any defined Bootstrap Tooltips. + * + * @see https://getbootstrap.com/docs/5.0/components/tooltips + */ +export function initBootstrap(): void { + for (const func of [initTooltips, initModals, initMasonry, initTabs]) { + func(); + } +} diff --git a/netbox/project-static/src/buttons.ts b/netbox/project-static/src/buttons.ts index 6fe107a54..ce7386f1a 100644 --- a/netbox/project-static/src/buttons.ts +++ b/netbox/project-static/src/buttons.ts @@ -1,5 +1,5 @@ -import { createToast } from './toast'; -import { isTruthy, getElements, apiPatch, hasError } from './util'; +import { createToast } from './bs'; +import { isTruthy, getElements, apiPatch, hasError, slugify } from './util'; /** * Add onClick callback for toggling rack elevation images. @@ -91,8 +91,39 @@ function initConnectionToggle() { } } +/** + * If a slug field exists, add event listeners to handle automatically generating its value. + */ +function initReslug(): 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); + }); +} + export function initButtons() { - for (const func of [initRackElevation, initConnectionToggle]) { + for (const func of [initRackElevation, initConnectionToggle, initReslug]) { func(); } } diff --git a/netbox/project-static/src/device/config.ts b/netbox/project-static/src/device/config.ts index e64793c41..3ea96caa6 100644 --- a/netbox/project-static/src/device/config.ts +++ b/netbox/project-static/src/device/config.ts @@ -1,4 +1,4 @@ -import { createToast } from '../toast'; +import { createToast } from '../bs'; import { apiGetBase, getNetboxData, hasError, toggleLoader } from '../util'; /** diff --git a/netbox/project-static/src/device/lldp.ts b/netbox/project-static/src/device/lldp.ts index 3f1b802c7..71a8854df 100644 --- a/netbox/project-static/src/device/lldp.ts +++ b/netbox/project-static/src/device/lldp.ts @@ -1,4 +1,4 @@ -import { createToast } from '../toast'; +import { createToast } from '../bs'; import { getNetboxData, apiGetBase, hasError, isTruthy, toggleLoader } from '../util'; /** diff --git a/netbox/project-static/src/forms.ts b/netbox/project-static/src/forms.ts index b1bbb17a2..3a340c044 100644 --- a/netbox/project-static/src/forms.ts +++ b/netbox/project-static/src/forms.ts @@ -23,7 +23,7 @@ export function getFormData(element: HTMLFormElement): URLSearchParams { /** * Set the value of the number input field based on the selection of the dropdown. */ -export function initSpeedSelector(): void { +function initSpeedSelector(): void { for (const element of getElements('a.set_speed')) { if (element !== null) { function handleClick(event: Event) { @@ -266,7 +266,7 @@ function initScopeSelector() { } export function initForms() { - for (const func of [initFormElements, initMoveButtons, initScopeSelector]) { + for (const func of [initFormElements, initMoveButtons, initSpeedSelector, initScopeSelector]) { func(); } } diff --git a/netbox/project-static/src/jobs.ts b/netbox/project-static/src/jobs.ts index 1c6587fe8..cc3fb8494 100644 --- a/netbox/project-static/src/jobs.ts +++ b/netbox/project-static/src/jobs.ts @@ -1,4 +1,4 @@ -import { createToast } from './toast'; +import { createToast } from './bs'; import { apiGetBase, hasError } from './util'; let timeout: number = 1000; diff --git a/netbox/project-static/src/messages.ts b/netbox/project-static/src/messages.ts new file mode 100644 index 000000000..e2ccabf5b --- /dev/null +++ b/netbox/project-static/src/messages.ts @@ -0,0 +1,16 @@ +import { Toast } from 'bootstrap'; + +/** + * Find any active messages from django.contrib.messages and show them in a toast. + */ +export function initMessages(): 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/netbox.ts b/netbox/project-static/src/netbox.ts index a7468407e..ee6aeabb0 100644 --- a/netbox/project-static/src/netbox.ts +++ b/netbox/project-static/src/netbox.ts @@ -1,121 +1,34 @@ -import { Modal, 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 { initForms } from './forms'; +import { initBootstrap } from './bs'; +import { initSearch } from './search'; +import { initSelect } from './select'; import { initButtons } from './buttons'; +import { initSecrets } from './secrets'; +import { initMessages } from './messages'; import { initClipboard } from './clipboard'; -import { initSearchBar, initInterfaceFilter } from './search'; -import { initGenerateKeyPair, initLockUnlock, initGetSessionKey } from './secrets'; -import { initTabs } from './tabs'; +import { initDateSelector } from './dateSelector'; + import { initTableConfig } from './tableConfig'; -import { getElements } from './util'; -const INITIALIZERS = [ - initSearchBar, - initMasonry, - bindReslug, - initApiSelect, - initStaticSelect, - initDateSelector, - initSpeedSelector, - initColorSelect, - initButtons, - initClipboard, - initGenerateKeyPair, - initLockUnlock, - initGetSessionKey, - initInterfaceFilter, - initTableConfig, - initTabs, -] as (() => void)[]; - -/** - * Enable any defined Bootstrap Tooltips. - * - * @see https://getbootstrap.com/docs/5.0/components/tooltips - */ -function initBootstrap(): void { - if (document !== null) { - for (const tooltip of getElements('[data-bs-toggle="tooltip"]')) { - new Tooltip(tooltip, { container: 'body', boundary: 'window' }); - } - for (const modal of getElements('[data-bs-toggle="modal"]')) { - new Modal(modal); - } - initMessageToasts(); - initForms(); +function init() { + for (const init of [ + initBootstrap, + initMessages, + initForms, + initSearch, + initSelect, + initDateSelector, + initButtons, + initClipboard, + initSecrets, + initTableConfig, + ]) { + init(); } } -/** - * Initialize masonry-layout for homepage (or any other masonry layout cards). - */ -function initMasonry(): void { - if (document !== null) { - for (const grid of getElements('.masonry')) { - 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(); +} else { + document.addEventListener('DOMContentLoaded', init); } diff --git a/netbox/project-static/src/search.ts b/netbox/project-static/src/search.ts index 2f8670053..43fba921d 100644 --- a/netbox/project-static/src/search.ts +++ b/netbox/project-static/src/search.ts @@ -8,7 +8,7 @@ function isSearchButton(el: any): el is SearchFilterButton { return el?.dataset?.searchValue ?? null !== null; } -export function initSearchBar() { +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; @@ -41,7 +41,7 @@ export function initSearchBar() { /** * Initialize Interface Table Filter Elements. */ -export function initInterfaceFilter() { +function initInterfaceFilter() { for (const element of getElements('input.interface-filter')) { /** * Filter on-page table by input text. @@ -79,3 +79,9 @@ export function initInterfaceFilter() { element.addEventListener('keyup', handleInput); } } + +export function initSearch() { + for (const func of [initSearchBar, initInterfaceFilter]) { + func(); + } +} diff --git a/netbox/project-static/src/secrets.ts b/netbox/project-static/src/secrets.ts index d2104f959..dd4d77eb5 100644 --- a/netbox/project-static/src/secrets.ts +++ b/netbox/project-static/src/secrets.ts @@ -1,11 +1,11 @@ import { Modal } from 'bootstrap'; +import { createToast } from './bs'; import { apiGetBase, apiPostForm, getElements, isApiError, hasError } from './util'; -import { createToast } from './toast'; /** * Initialize Generate Private Key Pair Elements. */ -export function initGenerateKeyPair() { +function initGenerateKeyPair() { const element = document.getElementById('new_keypair_modal') as HTMLDivElement; const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement; // If the elements are not loaded, stop. @@ -86,7 +86,7 @@ function toggleSecretButtons(id: string, action: 'lock' | 'unlock') { /** * Initialize Lock & Unlock button event listeners & callbacks. */ -export function initLockUnlock() { +function initLockUnlock() { const privateKeyModalElem = document.getElementById('privkey_modal'); if (privateKeyModalElem === null) { return; @@ -184,7 +184,7 @@ function requestSessionKey(privateKey: string) { /** * Initialize Request Session Key Elements. */ -export function initGetSessionKey() { +function initGetSessionKey() { for (const element of getElements('#request_session_key')) { /** * Send the user's input private key to the API to get a session key, which will be stored as @@ -200,3 +200,9 @@ export function initGetSessionKey() { element.addEventListener('click', handleClick); } } + +export function initSecrets() { + for (const func of [initGenerateKeyPair, initLockUnlock, initGetSessionKey]) { + func(); + } +} diff --git a/netbox/project-static/src/select-choices/api.ts b/netbox/project-static/src/select-choices/api.ts deleted file mode 100644 index b1864f739..000000000 --- a/netbox/project-static/src/select-choices/api.ts +++ /dev/null @@ -1,167 +0,0 @@ -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 deleted file mode 100644 index 209b690c2..000000000 --- a/netbox/project-static/src/select-choices/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 2500d6d6a..000000000 --- a/netbox/project-static/src/select-choices/static.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 index 8cea67672..9db037c05 100644 --- a/netbox/project-static/src/select/api.ts +++ b/netbox/project-static/src/select/api.ts @@ -1,7 +1,7 @@ import SlimSelect from 'slim-select'; import queryString from 'query-string'; -import { getApiData, isApiError, getElements, isTruthy } from '../util'; -import { createToast } from '../toast'; +import { getApiData, isApiError, getElements, isTruthy, hasError } from '../util'; +import { createToast } from '../bs'; import { setOptionStyles, getFilteredBy, toggle } from './util'; import type { Option } from 'slim-select/dist/data'; @@ -38,7 +38,7 @@ const REPLACE_PATTERNS = [ [new RegExp(/termination_(a|b)_(.+)/g), '$2_id'], // A tenant's group relationship field is `group`, but the field name is `tenant_group`. [new RegExp(/tenant_(group)/g), '$1_id'], - // Append `_id` to any fields + // Append `_id` to any fields [new RegExp(/^([A-Za-z0-9]+)(_id)?$/g), '$1_id'], ] as ReplaceTuple[]; @@ -72,9 +72,12 @@ async function getOptions( .map(option => option.value); return getApiData(url).then(data => { - if (isApiError(data)) { - const toast = createToast('danger', data.exception, data.error); - toast.show(); + if (hasError(data)) { + if (isApiError(data)) { + createToast('danger', data.exception, data.error).show(); + return [PLACEHOLDER]; + } + createToast('danger', `Error Fetching Options for field ${select.name}`, data.error).show(); return [PLACEHOLDER]; } diff --git a/netbox/project-static/src/select/index.ts b/netbox/project-static/src/select/index.ts index a732be004..480737b02 100644 --- a/netbox/project-static/src/select/index.ts +++ b/netbox/project-static/src/select/index.ts @@ -1,3 +1,9 @@ -export * from './api'; -export * from './static'; -export * from './color'; +import { initApiSelect } from './api'; +import { initColorSelect } from './color'; +import { initStaticSelect } from './static'; + +export function initSelect() { + for (const func of [initApiSelect, initColorSelect, initStaticSelect]) { + func(); + } +} diff --git a/netbox/project-static/src/tableConfig.ts b/netbox/project-static/src/tableConfig.ts index f2403b14f..0eb6479ae 100644 --- a/netbox/project-static/src/tableConfig.ts +++ b/netbox/project-static/src/tableConfig.ts @@ -1,4 +1,4 @@ -import { createToast } from './toast'; +import { createToast } from './bs'; import { getElements, apiPatch, hasError, getSelectedOptions } from './util'; /** diff --git a/netbox/project-static/src/tabs.ts b/netbox/project-static/src/tabs.ts deleted file mode 100644 index 837335444..000000000 --- a/netbox/project-static/src/tabs.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Tab } from 'bootstrap'; -import { getElements } from './util'; - -/** - * Open the tab specified in the URL. For example, /dcim/device-types/1/#tab_frontports will - * change the open tab to the Front Ports tab. - */ -export function initTabs() { - const { hash } = location; - if (hash && hash.match(/^\#tab_.+$/)) { - // The tab element will have a data-bs-target attribute with a value of the object type for - // the corresponding tab. Once we drop the `tab_` prefix, the hash will match the target - // element's data-bs-target value. For example, `#tab_frontports` becomes `#frontports`. - const target = hash.replace('tab_', ''); - for (const element of getElements(`ul.nav.nav-tabs .nav-link[data-bs-target="${target}"]`)) { - // Instantiate a Bootstrap tab instance. - // See https://getbootstrap.com/docs/5.0/components/navs-tabs/#javascript-behavior - const tab = new Tab(element); - // Show the tab. - tab.show(); - } - } -} diff --git a/netbox/project-static/src/util.ts b/netbox/project-static/src/util.ts index 20bb3dacd..3c956b5a7 100644 --- a/netbox/project-static/src/util.ts +++ b/netbox/project-static/src/util.ts @@ -13,6 +13,22 @@ export function hasError(data: Record): data is ErrorBase { return 'error' in data; } +/** + * Create a slug from any input string. + * + * @param slug Original string. + * @param chars Maximum number of characters. + * @returns Slugified string. + */ +export 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 +} + /** * Type guard to determine if a value is not null, undefined, or empty. */