diff --git a/netbox/project-static/dist/config.js b/netbox/project-static/dist/config.js index 1763a8943..0f5723b02 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 66a880712..62f1744f8 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 c0c05058d..649d759ae 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 9843e27a6..9e9ea06b9 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 f2c9aa411..c5c9584a7 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 beaa000bc..d42cb3025 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 e1ffeadbc..1e5b6dd64 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 0314df851..acb54704c 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/dist/status.js b/netbox/project-static/dist/status.js index e9f67d191..a2d02a799 100644 Binary files a/netbox/project-static/dist/status.js and b/netbox/project-static/dist/status.js differ diff --git a/netbox/project-static/dist/status.js.map b/netbox/project-static/dist/status.js.map index 1d0eeebd6..cf69c347c 100644 Binary files a/netbox/project-static/dist/status.js.map and b/netbox/project-static/dist/status.js.map differ diff --git a/netbox/project-static/src/buttons.ts b/netbox/project-static/src/buttons.ts deleted file mode 100644 index da3a4aec0..000000000 --- a/netbox/project-static/src/buttons.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { createToast } from './bs'; -import { setColorMode } from './colorMode'; -import { objectDepthState } from './stores'; -import { - slugify, - isTruthy, - apiPatch, - hasError, - getElement, - getElements, - findFirstAdjacent, -} from './util'; - -import type { StateManager } from './state'; - -type ObjectDepthState = { hidden: boolean }; - -/** - * When the toggle button is clicked, swap the connection status via the API and toggle CSS - * classes to reflect the connection status. - * - * @param element Connection Toggle Button Element - */ -function toggleConnection(element: HTMLButtonElement): void { - const id = element.getAttribute('data'); - const connected = element.classList.contains('connected'); - const status = connected ? 'planned' : 'connected'; - - if (isTruthy(id)) { - apiPatch(`/api/dcim/cables/${id}/`, { status }).then(res => { - if (hasError(res)) { - // If the API responds with an error, show it to the user. - createToast('danger', 'Error', res.error).show(); - return; - } else { - // Get the button's row to change its styles. - const row = element.parentElement?.parentElement as HTMLTableRowElement; - // Get the button's icon to change its CSS class. - const icon = element.querySelector('i.mdi, span.mdi') as HTMLSpanElement; - if (connected) { - row.classList.remove('success'); - row.classList.add('info'); - element.classList.remove('connected', 'btn-warning'); - element.classList.add('btn-info'); - element.title = 'Mark Installed'; - icon.classList.remove('mdi-lan-disconnect'); - icon.classList.add('mdi-lan-connect'); - } else { - row.classList.remove('info'); - row.classList.add('success'); - element.classList.remove('btn-success'); - element.classList.add('connected', 'btn-warning'); - element.title = 'Mark Installed'; - icon.classList.remove('mdi-lan-connect'); - icon.classList.add('mdi-lan-disconnect'); - } - } - }); - } -} - -function initConnectionToggle(): void { - for (const element of getElements('button.cable-toggle')) { - element.addEventListener('click', () => toggleConnection(element)); - } -} - -/** - * Change toggle button's text and attribute to reflect the current state. - * - * @param hidden `true` if the current state is hidden, `false` otherwise. - * @param button Toggle element. - */ -function toggleDepthButton(hidden: boolean, button: HTMLButtonElement): void { - button.setAttribute('data-depth-indicators', hidden ? 'hidden' : 'shown'); - button.innerText = hidden ? 'Show Depth Indicators' : 'Hide Depth Indicators'; -} - -/** - * Show all depth indicators. - */ -function showDepthIndicators(): void { - for (const element of getElements('.record-depth')) { - element.style.display = ''; - } -} - -/** - * Hide all depth indicators. - */ -function hideDepthIndicators(): void { - for (const element of getElements('.record-depth')) { - element.style.display = 'none'; - } -} - -/** - * Update object depth local state and visualization when the button is clicked. - * - * @param state State instance. - * @param button Toggle element. - */ -function handleDepthToggle(state: StateManager, button: HTMLButtonElement): void { - const initiallyHidden = state.get('hidden'); - state.set('hidden', !initiallyHidden); - const hidden = state.get('hidden'); - - if (hidden) { - hideDepthIndicators(); - } else { - showDepthIndicators(); - } - toggleDepthButton(hidden, button); -} - -/** - * Initialize object depth toggle buttons. - */ -function initDepthToggle(): void { - const initiallyHidden = objectDepthState.get('hidden'); - - for (const button of getElements('button.toggle-depth')) { - toggleDepthButton(initiallyHidden, button); - - button.addEventListener( - 'click', - event => { - handleDepthToggle(objectDepthState, event.currentTarget as HTMLButtonElement); - }, - false, - ); - } - // Synchronize local state with default DOM elements. - if (initiallyHidden) { - hideDepthIndicators(); - } else if (!initiallyHidden) { - showDepthIndicators(); - } -} - -/** - * 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); - }); -} - -/** - * Perform actions in the UI based on the value of user profile updates. - * - * @param event Form Submit - */ -function handlePreferenceSave(event: Event): void { - // Create a FormData instance to access the form values. - const form = event.currentTarget as HTMLFormElement; - const formData = new FormData(form); - - // Update the UI color mode immediately when the user preference changes. - if (formData.get('ui.colormode') === 'dark') { - setColorMode('dark'); - } else if (formData.get('ui.colormode') === 'light') { - setColorMode('light'); - } -} - -/** - * Initialize handlers for user profile updates. - */ -function initPreferenceUpdate(): void { - const form = getElement('preferences-update'); - if (form !== null) { - form.addEventListener('submit', handlePreferenceSave); - } -} - -/** - * Show the select all card when the select all checkbox is checked, and sync the checkbox state - * with all the PK checkboxes in the table. - * - * @param event Change Event - */ -function handleSelectAllToggle(event: Event): void { - // Select all checkbox in header row. - const tableSelectAll = event.currentTarget as HTMLInputElement; - // Nearest table to the select all checkbox. - const table = findFirstAdjacent(tableSelectAll, 'table'); - // Select all confirmation card. - const confirmCard = document.getElementById('select-all-box'); - // Checkbox in confirmation card to signal if all objects should be selected. - const confirmCheckbox = document.getElementById('select-all') as Nullable; - - if (table !== null) { - for (const element of table.querySelectorAll( - 'input[type="checkbox"][name="pk"]', - )) { - if (tableSelectAll.checked) { - // Check all PK checkboxes if the select all checkbox is checked. - element.checked = true; - } else { - // Uncheck all PK checkboxes if the select all checkbox is unchecked. - element.checked = false; - } - } - if (confirmCard !== null) { - if (tableSelectAll.checked) { - // Unhide the select all confirmation card if the select all checkbox is checked. - confirmCard.classList.remove('d-none'); - } else { - // Hide the select all confirmation card if the select all checkbox is unchecked. - confirmCard.classList.add('d-none'); - if (confirmCheckbox !== null) { - // Uncheck the confirmation checkbox when the table checkbox is unchecked (after which - // the confirmation card will be hidden). - confirmCheckbox.checked = false; - } - } - } - } -} - -/** - * If any PK checkbox is checked, uncheck the select all table checkbox and the select all - * confirmation checkbox. - * - * @param event Change Event - */ -function handlePkCheck(event: Event): void { - const target = event.currentTarget as HTMLInputElement; - if (!target.checked) { - for (const element of getElements( - 'input[type="checkbox"].toggle', - 'input#select-all', - )) { - element.checked = false; - } - } -} - -/** - * Synchronize the select all confirmation checkbox state with the select all confirmation button - * disabled state. If the select all confirmation checkbox is checked, the buttons should be - * enabled. If not, the buttons should be disabled. - * - * @param event Change Event - */ -function handleSelectAll(event: Event): void { - const target = event.currentTarget as HTMLInputElement; - const selectAllBox = getElement('select-all-box'); - if (selectAllBox !== null) { - for (const button of selectAllBox.querySelectorAll( - 'button[type="submit"]', - )) { - if (target.checked) { - button.disabled = false; - } else { - button.disabled = true; - } - } - } -} - -/** - * Initialize table select all elements. - */ -function initSelectAll(): void { - for (const element of getElements( - 'table tr th > input[type="checkbox"].toggle', - )) { - element.addEventListener('change', handleSelectAllToggle); - } - for (const element of getElements('input[type="checkbox"][name="pk"]')) { - element.addEventListener('change', handlePkCheck); - } - const selectAll = getElement('select-all'); - - if (selectAll !== null) { - selectAll.addEventListener('change', handleSelectAll); - } -} - -function handlePerPageSelect(event: Event): void { - const select = event.currentTarget as HTMLSelectElement; - if (select.form !== null) { - select.form.submit(); - } -} - -function initPerPage(): void { - for (const element of getElements('select.per-page')) { - element.addEventListener('change', handlePerPageSelect); - } -} - -export function initButtons(): void { - for (const func of [ - initDepthToggle, - initConnectionToggle, - initReslug, - initSelectAll, - initPreferenceUpdate, - initPerPage, - ]) { - func(); - } -} diff --git a/netbox/project-static/src/buttons/connectionToggle.ts b/netbox/project-static/src/buttons/connectionToggle.ts new file mode 100644 index 000000000..6485bbb50 --- /dev/null +++ b/netbox/project-static/src/buttons/connectionToggle.ts @@ -0,0 +1,52 @@ +import { createToast } from '../bs'; +import { isTruthy, apiPatch, hasError, getElements } from '../util'; + +/** + * When the toggle button is clicked, swap the connection status via the API and toggle CSS + * classes to reflect the connection status. + * + * @param element Connection Toggle Button Element + */ +function toggleConnection(element: HTMLButtonElement): void { + const id = element.getAttribute('data'); + const connected = element.classList.contains('connected'); + const status = connected ? 'planned' : 'connected'; + + if (isTruthy(id)) { + apiPatch(`/api/dcim/cables/${id}/`, { status }).then(res => { + if (hasError(res)) { + // If the API responds with an error, show it to the user. + createToast('danger', 'Error', res.error).show(); + return; + } else { + // Get the button's row to change its styles. + const row = element.parentElement?.parentElement as HTMLTableRowElement; + // Get the button's icon to change its CSS class. + const icon = element.querySelector('i.mdi, span.mdi') as HTMLSpanElement; + if (connected) { + row.classList.remove('success'); + row.classList.add('info'); + element.classList.remove('connected', 'btn-warning'); + element.classList.add('btn-info'); + element.title = 'Mark Installed'; + icon.classList.remove('mdi-lan-disconnect'); + icon.classList.add('mdi-lan-connect'); + } else { + row.classList.remove('info'); + row.classList.add('success'); + element.classList.remove('btn-success'); + element.classList.add('connected', 'btn-warning'); + element.title = 'Mark Installed'; + icon.classList.remove('mdi-lan-connect'); + icon.classList.add('mdi-lan-disconnect'); + } + } + }); + } +} + +export function initConnectionToggle(): void { + for (const element of getElements('button.cable-toggle')) { + element.addEventListener('click', () => toggleConnection(element)); + } +} diff --git a/netbox/project-static/src/buttons/depthToggle.ts b/netbox/project-static/src/buttons/depthToggle.ts new file mode 100644 index 000000000..2e17ec02f --- /dev/null +++ b/netbox/project-static/src/buttons/depthToggle.ts @@ -0,0 +1,79 @@ +import { objectDepthState } from '../stores'; +import { getElements } from '../util'; + +import type { StateManager } from '../state'; + +type ObjectDepthState = { hidden: boolean }; + +/** + * Change toggle button's text and attribute to reflect the current state. + * + * @param hidden `true` if the current state is hidden, `false` otherwise. + * @param button Toggle element. + */ +function toggleDepthButton(hidden: boolean, button: HTMLButtonElement): void { + button.setAttribute('data-depth-indicators', hidden ? 'hidden' : 'shown'); + button.innerText = hidden ? 'Show Depth Indicators' : 'Hide Depth Indicators'; +} + +/** + * Show all depth indicators. + */ +function showDepthIndicators(): void { + for (const element of getElements('.record-depth')) { + element.style.display = ''; + } +} + +/** + * Hide all depth indicators. + */ +function hideDepthIndicators(): void { + for (const element of getElements('.record-depth')) { + element.style.display = 'none'; + } +} + +/** + * Update object depth local state and visualization when the button is clicked. + * + * @param state State instance. + * @param button Toggle element. + */ +function handleDepthToggle(state: StateManager, button: HTMLButtonElement): void { + const initiallyHidden = state.get('hidden'); + state.set('hidden', !initiallyHidden); + const hidden = state.get('hidden'); + + if (hidden) { + hideDepthIndicators(); + } else { + showDepthIndicators(); + } + toggleDepthButton(hidden, button); +} + +/** + * Initialize object depth toggle buttons. + */ +export function initDepthToggle(): void { + const initiallyHidden = objectDepthState.get('hidden'); + + for (const button of getElements('button.toggle-depth')) { + toggleDepthButton(initiallyHidden, button); + + button.addEventListener( + 'click', + event => { + handleDepthToggle(objectDepthState, event.currentTarget as HTMLButtonElement); + }, + false, + ); + } + // Synchronize local state with default DOM elements. + if (initiallyHidden) { + hideDepthIndicators(); + } else if (!initiallyHidden) { + showDepthIndicators(); + } +} diff --git a/netbox/project-static/src/buttons/index.ts b/netbox/project-static/src/buttons/index.ts new file mode 100644 index 000000000..1fcc7b87e --- /dev/null +++ b/netbox/project-static/src/buttons/index.ts @@ -0,0 +1,21 @@ +import { initConnectionToggle } from './connectionToggle'; +import { initDepthToggle } from './depthToggle'; +import { initMoveButtons } from './moveOptions'; +import { initPerPage } from './pagination'; +import { initPreferenceUpdate } from './preferences'; +import { initReslug } from './reslug'; +import { initSelectAll } from './selectAll'; + +export function initButtons(): void { + for (const func of [ + initDepthToggle, + initConnectionToggle, + initReslug, + initSelectAll, + initPreferenceUpdate, + initPerPage, + initMoveButtons, + ]) { + func(); + } +} diff --git a/netbox/project-static/src/buttons/moveOptions.ts b/netbox/project-static/src/buttons/moveOptions.ts new file mode 100644 index 000000000..fee36d609 --- /dev/null +++ b/netbox/project-static/src/buttons/moveOptions.ts @@ -0,0 +1,61 @@ +import { getElements } from '../util'; + +/** + * Move selected options of a select element up in order. + * + * Adapted from: + * @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html + * @param element Select Element + */ +function moveOptionUp(element: HTMLSelectElement): void { + const options = Array.from(element.options); + for (let i = 1; i < options.length; i++) { + const option = options[i]; + if (option.selected) { + element.removeChild(option); + element.insertBefore(option, element.options[i - 1]); + } + } +} + +/** + * Move selected options of a select element down in order. + * + * Adapted from: + * @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html + * @param element Select Element + */ +function moveOptionDown(element: HTMLSelectElement): void { + const options = Array.from(element.options); + for (let i = options.length - 2; i >= 0; i--) { + let option = options[i]; + if (option.selected) { + let next = element.options[i + 1]; + option = element.removeChild(option); + next = element.replaceChild(option, next); + element.insertBefore(next, option); + } + } +} + +/** + * Initialize move up/down buttons. + */ +export function initMoveButtons(): void { + for (const button of getElements('#move-option-up')) { + const target = button.getAttribute('data-target'); + if (target !== null) { + for (const select of getElements(`#${target}`)) { + button.addEventListener('click', () => moveOptionUp(select)); + } + } + } + for (const button of getElements('#move-option-down')) { + const target = button.getAttribute('data-target'); + if (target !== null) { + for (const select of getElements(`#${target}`)) { + button.addEventListener('click', () => moveOptionDown(select)); + } + } + } +} diff --git a/netbox/project-static/src/buttons/pagination.ts b/netbox/project-static/src/buttons/pagination.ts new file mode 100644 index 000000000..670dc7390 --- /dev/null +++ b/netbox/project-static/src/buttons/pagination.ts @@ -0,0 +1,14 @@ +import { getElements } from '../util'; + +function handlePerPageSelect(event: Event): void { + const select = event.currentTarget as HTMLSelectElement; + if (select.form !== null) { + select.form.submit(); + } +} + +export function initPerPage(): void { + for (const element of getElements('select.per-page')) { + element.addEventListener('change', handlePerPageSelect); + } +} diff --git a/netbox/project-static/src/buttons/preferences.ts b/netbox/project-static/src/buttons/preferences.ts new file mode 100644 index 000000000..6e8b21c02 --- /dev/null +++ b/netbox/project-static/src/buttons/preferences.ts @@ -0,0 +1,30 @@ +import { setColorMode } from '../colorMode'; +import { getElement } from '../util'; + +/** + * Perform actions in the UI based on the value of user profile updates. + * + * @param event Form Submit + */ +function handlePreferenceSave(event: Event): void { + // Create a FormData instance to access the form values. + const form = event.currentTarget as HTMLFormElement; + const formData = new FormData(form); + + // Update the UI color mode immediately when the user preference changes. + if (formData.get('ui.colormode') === 'dark') { + setColorMode('dark'); + } else if (formData.get('ui.colormode') === 'light') { + setColorMode('light'); + } +} + +/** + * Initialize handlers for user profile updates. + */ +export function initPreferenceUpdate(): void { + const form = getElement('preferences-update'); + if (form !== null) { + form.addEventListener('submit', handlePreferenceSave); + } +} diff --git a/netbox/project-static/src/buttons/reslug.ts b/netbox/project-static/src/buttons/reslug.ts new file mode 100644 index 000000000..2549bf112 --- /dev/null +++ b/netbox/project-static/src/buttons/reslug.ts @@ -0,0 +1,46 @@ +/** + * 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. + */ +export 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); + }); +} diff --git a/netbox/project-static/src/buttons/selectAll.ts b/netbox/project-static/src/buttons/selectAll.ts new file mode 100644 index 000000000..8b62ef0a0 --- /dev/null +++ b/netbox/project-static/src/buttons/selectAll.ts @@ -0,0 +1,106 @@ +import { getElement, getElements, findFirstAdjacent } from '../util'; + +/** + * If any PK checkbox is checked, uncheck the select all table checkbox and the select all + * confirmation checkbox. + * + * @param event Change Event + */ +function handlePkCheck(event: Event): void { + const target = event.currentTarget as HTMLInputElement; + if (!target.checked) { + for (const element of getElements( + 'input[type="checkbox"].toggle', + 'input#select-all', + )) { + element.checked = false; + } + } +} + +/** + * Show the select all card when the select all checkbox is checked, and sync the checkbox state + * with all the PK checkboxes in the table. + * + * @param event Change Event + */ +function handleSelectAllToggle(event: Event): void { + // Select all checkbox in header row. + const tableSelectAll = event.currentTarget as HTMLInputElement; + // Nearest table to the select all checkbox. + const table = findFirstAdjacent(tableSelectAll, 'table'); + // Select all confirmation card. + const confirmCard = document.getElementById('select-all-box'); + // Checkbox in confirmation card to signal if all objects should be selected. + const confirmCheckbox = document.getElementById('select-all') as Nullable; + + if (table !== null) { + for (const element of table.querySelectorAll( + 'input[type="checkbox"][name="pk"]', + )) { + if (tableSelectAll.checked) { + // Check all PK checkboxes if the select all checkbox is checked. + element.checked = true; + } else { + // Uncheck all PK checkboxes if the select all checkbox is unchecked. + element.checked = false; + } + } + if (confirmCard !== null) { + if (tableSelectAll.checked) { + // Unhide the select all confirmation card if the select all checkbox is checked. + confirmCard.classList.remove('d-none'); + } else { + // Hide the select all confirmation card if the select all checkbox is unchecked. + confirmCard.classList.add('d-none'); + if (confirmCheckbox !== null) { + // Uncheck the confirmation checkbox when the table checkbox is unchecked (after which + // the confirmation card will be hidden). + confirmCheckbox.checked = false; + } + } + } + } +} + +/** + * Synchronize the select all confirmation checkbox state with the select all confirmation button + * disabled state. If the select all confirmation checkbox is checked, the buttons should be + * enabled. If not, the buttons should be disabled. + * + * @param event Change Event + */ +function handleSelectAll(event: Event): void { + const target = event.currentTarget as HTMLInputElement; + const selectAllBox = getElement('select-all-box'); + if (selectAllBox !== null) { + for (const button of selectAllBox.querySelectorAll( + 'button[type="submit"]', + )) { + if (target.checked) { + button.disabled = false; + } else { + button.disabled = true; + } + } + } +} + +/** + * Initialize table select all elements. + */ +export function initSelectAll(): void { + for (const element of getElements( + 'table tr th > input[type="checkbox"].toggle', + )) { + element.addEventListener('change', handleSelectAllToggle); + } + for (const element of getElements('input[type="checkbox"][name="pk"]')) { + element.addEventListener('change', handlePkCheck); + } + const selectAll = getElement('select-all'); + + if (selectAll !== null) { + selectAll.addEventListener('change', handleSelectAll); + } +} diff --git a/netbox/project-static/src/forms.ts b/netbox/project-static/src/forms.ts deleted file mode 100644 index 1dfd20cb5..000000000 --- a/netbox/project-static/src/forms.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { getElements, scrollTo, findFirstAdjacent, isTruthy } from './util'; - -type ShowHideMap = { - default: { hide: string[]; show: string[] }; - [k: string]: { hide: string[]; show: string[] }; -}; - -/** - * Handle bulk add/edit/rename form actions. - * - * @param event Click Event - */ -function handleFormActionClick(event: Event): void { - event.preventDefault(); - const element = event.currentTarget as HTMLElement; - if (element !== null) { - const form = findFirstAdjacent(element, 'form'); - const href = element.getAttribute('href'); - if (form !== null && isTruthy(href)) { - form.setAttribute('action', href); - form.submit(); - } - } -} - -/** - * Initialize bulk form action links. - */ -function initFormActions() { - for (const element of getElements('a.formaction')) { - element.addEventListener('click', handleFormActionClick); - } -} - -/** - * 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. - */ -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); - } - } -} - -function handleFormSubmit(event: Event, form: HTMLFormElement): void { - // Track the names of each invalid field. - const invalids = new Set(); - - for (const element of form.querySelectorAll('*[name]')) { - 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 = form.elements.namedItem(Array.from(invalids)[0]) as Element; - scrollTo(firstInvalid); - - // If the form has invalid fields, don't submit it. - event.preventDefault(); - } -} - -/** - * 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. - */ -function initFormElements() { - for (const form of getElements('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]'); - - for (const submitter of submitters) { - // Add the event listener to each submitter. - submitter.addEventListener('click', event => handleFormSubmit(event, form)); - } - } -} - -/** - * Move selected options of a select element up in order. - * - * Adapted from: - * @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html - * @param element Select Element - */ -function moveOptionUp(element: HTMLSelectElement): void { - const options = Array.from(element.options); - for (let i = 1; i < options.length; i++) { - const option = options[i]; - if (option.selected) { - element.removeChild(option); - element.insertBefore(option, element.options[i - 1]); - } - } -} - -/** - * Move selected options of a select element down in order. - * - * Adapted from: - * @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html - * @param element Select Element - */ -function moveOptionDown(element: HTMLSelectElement): void { - const options = Array.from(element.options); - for (let i = options.length - 2; i >= 0; i--) { - let option = options[i]; - if (option.selected) { - let next = element.options[i + 1]; - option = element.removeChild(option); - next = element.replaceChild(option, next); - element.insertBefore(next, option); - } - } -} - -/** - * Initialize move up/down buttons. - */ -function initMoveButtons() { - for (const button of getElements('#move-option-up')) { - const target = button.getAttribute('data-target'); - if (target !== null) { - for (const select of getElements(`#${target}`)) { - button.addEventListener('click', () => moveOptionUp(select)); - } - } - } - for (const button of getElements('#move-option-down')) { - const target = button.getAttribute('data-target'); - if (target !== null) { - for (const select of getElements(`#${target}`)) { - button.addEventListener('click', () => moveOptionDown(select)); - } - } - } -} - -/** - * Mapping of scope names to arrays of object types whose fields should be hidden or shown when - * the scope type (key) is selected. - * - * For example, if `region` is the scope type, the fields with IDs listed in - * showHideMap.region.hide should be hidden, and the fields with IDs listed in - * showHideMap.region.show should be shown. - */ -const showHideMap: ShowHideMap = { - region: { - hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'], - show: ['id_region'], - }, - 'site group': { - hide: ['id_region', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'], - show: ['id_sitegroup'], - }, - site: { - hide: ['id_location', 'id_rack', 'id_clustergroup', 'id_cluster'], - show: ['id_region', 'id_sitegroup', 'id_site'], - }, - location: { - hide: ['id_rack', 'id_clustergroup', 'id_cluster'], - show: ['id_region', 'id_sitegroup', 'id_site', 'id_location'], - }, - rack: { - hide: ['id_clustergroup', 'id_cluster'], - show: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'], - }, - 'cluster group': { - hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_cluster'], - show: ['id_clustergroup'], - }, - cluster: { - hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'], - show: ['id_clustergroup', 'id_cluster'], - }, - default: { - hide: [ - 'id_region', - 'id_sitegroup', - 'id_site', - 'id_location', - 'id_rack', - 'id_clustergroup', - 'id_cluster', - ], - show: [], - }, -}; - -/** - * Toggle visibility of a given element's parent. - * @param query CSS Query. - * @param action Show or Hide the Parent. - */ -function toggleParentVisibility(query: string, action: 'show' | 'hide') { - for (const element of getElements(query)) { - if (action === 'show') { - element.parentElement?.classList.remove('d-none', 'invisible'); - } else { - element.parentElement?.classList.add('d-none', 'invisible'); - } - } -} - -/** - * Handle changes to the Scope Type field. - */ -function handleScopeChange(event: Event) { - const element = event.currentTarget as HTMLSelectElement; - // Scope type's innerText looks something like `DCIM > region`. - const scopeType = element.options[element.selectedIndex].innerText.toLowerCase(); - - for (const [scope, fields] of Object.entries(showHideMap)) { - // If the scope type ends with the specified scope, toggle its field visibility according to - // the show/hide values. - if (scopeType.endsWith(scope)) { - for (const field of fields.hide) { - toggleParentVisibility(`#${field}`, 'hide'); - } - for (const field of fields.show) { - toggleParentVisibility(`#${field}`, 'show'); - } - // Stop on first match. - break; - } else { - // Otherwise, hide all fields. - for (const field of showHideMap.default.hide) { - toggleParentVisibility(`#${field}`, 'hide'); - } - } - } -} - -/** - * Initialize scope type select event listeners. - */ -function initScopeSelector() { - for (const element of getElements('#id_scope_type')) { - element.addEventListener('change', handleScopeChange); - } -} - -export function initForms(): void { - for (const func of [ - initFormElements, - initFormActions, - initMoveButtons, - initSpeedSelector, - initScopeSelector, - ]) { - func(); - } -} diff --git a/netbox/project-static/src/forms/actions.ts b/netbox/project-static/src/forms/actions.ts new file mode 100644 index 000000000..a83521d0d --- /dev/null +++ b/netbox/project-static/src/forms/actions.ts @@ -0,0 +1,28 @@ +import { getElements, findFirstAdjacent, isTruthy } from '../util'; + +/** + * Handle bulk add/edit/rename form actions. + * + * @param event Click Event + */ +function handleFormActionClick(event: Event): void { + event.preventDefault(); + const element = event.currentTarget as HTMLElement; + if (element !== null) { + const form = findFirstAdjacent(element, 'form'); + const href = element.getAttribute('href'); + if (form !== null && isTruthy(href)) { + form.setAttribute('action', href); + form.submit(); + } + } +} + +/** + * Initialize bulk form action links. + */ +export function initFormActions(): void { + for (const element of getElements('a.formaction')) { + element.addEventListener('click', handleFormActionClick); + } +} diff --git a/netbox/project-static/src/forms/elements.ts b/netbox/project-static/src/forms/elements.ts new file mode 100644 index 000000000..978c25e10 --- /dev/null +++ b/netbox/project-static/src/forms/elements.ts @@ -0,0 +1,57 @@ +import { getElements, scrollTo } from '../util'; + +function handleFormSubmit(event: Event, form: HTMLFormElement): void { + // Track the names of each invalid field. + const invalids = new Set(); + + for (const element of form.querySelectorAll('*[name]')) { + 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 = form.elements.namedItem(Array.from(invalids)[0]) as Element; + scrollTo(firstInvalid); + + // If the form has invalid fields, don't submit it. + event.preventDefault(); + } +} + +/** + * 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 initFormElements(): void { + for (const form of getElements('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]'); + + for (const submitter of submitters) { + // Add the event listener to each submitter. + submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form)); + } + } +} diff --git a/netbox/project-static/src/forms/index.ts b/netbox/project-static/src/forms/index.ts new file mode 100644 index 000000000..2c409dd76 --- /dev/null +++ b/netbox/project-static/src/forms/index.ts @@ -0,0 +1,17 @@ +import { initFormActions } from './actions'; +import { initFormElements } from './elements'; +import { initSpeedSelector } from './speedSelector'; +import { initScopeSelector } from './scopeSelector'; +import { initVlanTags } from './vlanTags'; + +export function initForms(): void { + for (const func of [ + initFormActions, + initFormElements, + initSpeedSelector, + initScopeSelector, + initVlanTags, + ]) { + func(); + } +} diff --git a/netbox/project-static/src/forms/scopeSelector.ts b/netbox/project-static/src/forms/scopeSelector.ts new file mode 100644 index 000000000..ad107f9b3 --- /dev/null +++ b/netbox/project-static/src/forms/scopeSelector.ts @@ -0,0 +1,109 @@ +import { getElements } from '../util'; + +type ShowHideMap = { + default: { hide: string[]; show: string[] }; + [k: string]: { hide: string[]; show: string[] }; +}; + +/** + * Mapping of scope names to arrays of object types whose fields should be hidden or shown when + * the scope type (key) is selected. + * + * For example, if `region` is the scope type, the fields with IDs listed in + * showHideMap.region.hide should be hidden, and the fields with IDs listed in + * showHideMap.region.show should be shown. + */ +const showHideMap: ShowHideMap = { + region: { + hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'], + show: ['id_region'], + }, + 'site group': { + hide: ['id_region', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'], + show: ['id_sitegroup'], + }, + site: { + hide: ['id_location', 'id_rack', 'id_clustergroup', 'id_cluster'], + show: ['id_region', 'id_sitegroup', 'id_site'], + }, + location: { + hide: ['id_rack', 'id_clustergroup', 'id_cluster'], + show: ['id_region', 'id_sitegroup', 'id_site', 'id_location'], + }, + rack: { + hide: ['id_clustergroup', 'id_cluster'], + show: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'], + }, + 'cluster group': { + hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_cluster'], + show: ['id_clustergroup'], + }, + cluster: { + hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'], + show: ['id_clustergroup', 'id_cluster'], + }, + default: { + hide: [ + 'id_region', + 'id_sitegroup', + 'id_site', + 'id_location', + 'id_rack', + 'id_clustergroup', + 'id_cluster', + ], + show: [], + }, +}; +/** + * Toggle visibility of a given element's parent. + * @param query CSS Query. + * @param action Show or Hide the Parent. + */ +function toggleParentVisibility(query: string, action: 'show' | 'hide') { + for (const element of getElements(query)) { + if (action === 'show') { + element.parentElement?.classList.remove('d-none', 'invisible'); + } else { + element.parentElement?.classList.add('d-none', 'invisible'); + } + } +} + +/** + * Handle changes to the Scope Type field. + */ +function handleScopeChange(event: Event) { + const element = event.currentTarget as HTMLSelectElement; + // Scope type's innerText looks something like `DCIM > region`. + const scopeType = element.options[element.selectedIndex].innerText.toLowerCase(); + + for (const [scope, fields] of Object.entries(showHideMap)) { + // If the scope type ends with the specified scope, toggle its field visibility according to + // the show/hide values. + if (scopeType.endsWith(scope)) { + for (const field of fields.hide) { + toggleParentVisibility(`#${field}`, 'hide'); + } + for (const field of fields.show) { + toggleParentVisibility(`#${field}`, 'show'); + } + // Stop on first match. + break; + } else { + // Otherwise, hide all fields. + for (const field of showHideMap.default.hide) { + toggleParentVisibility(`#${field}`, 'hide'); + } + } + } +} + +/** + * Initialize scope type select event listeners. + */ +export function initScopeSelector(): void { + for (const element of getElements('#id_scope_type')) { + element.addEventListener('change', handleScopeChange); + } +} diff --git a/netbox/project-static/src/forms/speedSelector.ts b/netbox/project-static/src/forms/speedSelector.ts new file mode 100644 index 000000000..9195afce3 --- /dev/null +++ b/netbox/project-static/src/forms/speedSelector.ts @@ -0,0 +1,24 @@ +import { getElements } from '../util'; + +/** + * 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); + } + } +} diff --git a/netbox/project-static/src/forms/vlanTags.ts b/netbox/project-static/src/forms/vlanTags.ts new file mode 100644 index 000000000..03ec73e60 --- /dev/null +++ b/netbox/project-static/src/forms/vlanTags.ts @@ -0,0 +1,116 @@ +import { all, getElement, resetSelect, toggleVisibility } from '../util'; + +/** + * Get a select element's containing `.row` element. + * + * @param element Select element. + * @returns Containing row element. + */ +function fieldContainer(element: Nullable): Nullable { + const container = element?.parentElement?.parentElement ?? null; + if (container !== null && container.classList.contains('row')) { + return container; + } + return null; +} + +/** + * Toggle element visibility when the mode field does not have a value. + */ +function handleModeNone(): void { + const elements = [ + getElement('id_tagged_vlans'), + getElement('id_untagged_vlan'), + getElement('id_vlan_group'), + ]; + + if (all(elements)) { + const [taggedVlans, untaggedVlan] = elements; + resetSelect(untaggedVlan); + resetSelect(taggedVlans); + for (const element of elements) { + toggleVisibility(fieldContainer(element), 'hide'); + } + } +} + +/** + * Toggle element visibility when the mode field's value is Access. + */ +function handleModeAccess(): void { + const elements = [ + getElement('id_tagged_vlans'), + getElement('id_untagged_vlan'), + getElement('id_vlan_group'), + ]; + if (all(elements)) { + const [taggedVlans, untaggedVlan, vlanGroup] = elements; + resetSelect(taggedVlans); + toggleVisibility(fieldContainer(vlanGroup), 'show'); + toggleVisibility(fieldContainer(untaggedVlan), 'show'); + toggleVisibility(fieldContainer(taggedVlans), 'hide'); + } +} + +/** + * Toggle element visibility when the mode field's value is Tagged. + */ +function handleModeTagged(): void { + const elements = [ + getElement('id_tagged_vlans'), + getElement('id_untagged_vlan'), + getElement('id_vlan_group'), + ]; + if (all(elements)) { + const [taggedVlans, untaggedVlan, vlanGroup] = elements; + toggleVisibility(fieldContainer(taggedVlans), 'show'); + toggleVisibility(fieldContainer(vlanGroup), 'show'); + toggleVisibility(fieldContainer(untaggedVlan), 'show'); + } +} + +/** + * Toggle element visibility when the mode field's value is Tagged (All). + */ +function handleModeTaggedAll(): void { + const elements = [ + getElement('id_tagged_vlans'), + getElement('id_untagged_vlan'), + getElement('id_vlan_group'), + ]; + if (all(elements)) { + const [taggedVlans, untaggedVlan, vlanGroup] = elements; + resetSelect(taggedVlans); + toggleVisibility(fieldContainer(vlanGroup), 'show'); + toggleVisibility(fieldContainer(untaggedVlan), 'show'); + toggleVisibility(fieldContainer(taggedVlans), 'hide'); + } +} + +/** + * Reset field visibility when the mode field's value changes. + */ +function handleModeChange(element: HTMLSelectElement): void { + switch (element.value) { + case 'access': + handleModeAccess(); + break; + case 'tagged': + handleModeTagged(); + break; + case 'tagged-all': + handleModeTaggedAll(); + break; + case '': + handleModeNone(); + break; + } +} + +export function initVlanTags(): void { + const element = getElement('id_mode'); + if (element !== null) { + element.addEventListener('change', () => handleModeChange(element)); + handleModeChange(element); + } +} diff --git a/netbox/project-static/src/util.ts b/netbox/project-static/src/util.ts index 3f399b1c2..50211ed7b 100644 --- a/netbox/project-static/src/util.ts +++ b/netbox/project-static/src/util.ts @@ -26,22 +26,6 @@ export function hasMore(data: APIAnswer): data is APIAnswerWithNe return typeof data.next === 'string'; } -/** - * 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. */ @@ -59,6 +43,45 @@ export function isTruthy(value: V): value is NonNullable { 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`. */ @@ -245,16 +268,38 @@ export function getNetboxData(key: string): string | null { 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')) { - if (action === 'show') { - element.classList.remove('d-none'); - } else { - element.classList.add('d-none'); - } + toggleVisibility(element, action); } }