Clean up TypeScript file structure, fix missing VLAN tag visibility logic

This commit is contained in:
Matt 2021-08-24 14:52:24 -07:00
parent 85b61c0b7e
commit 2e90f22529
27 changed files with 826 additions and 653 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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<HTMLButtonElement>('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<HTMLDivElement>('.record-depth')) {
element.style.display = '';
}
}
/**
* Hide all depth indicators.
*/
function hideDepthIndicators(): void {
for (const element of getElements<HTMLDivElement>('.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<ObjectDepthState>, 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<HTMLButtonElement>('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<HTMLFormElement>('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<HTMLInputElement>(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<HTMLInputElement>;
if (table !== null) {
for (const element of table.querySelectorAll<HTMLInputElement>(
'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<HTMLInputElement>(
'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<HTMLDivElement>('select-all-box');
if (selectAllBox !== null) {
for (const button of selectAllBox.querySelectorAll<HTMLButtonElement>(
'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<HTMLInputElement>(
'table tr th > input[type="checkbox"].toggle',
)) {
element.addEventListener('change', handleSelectAllToggle);
}
for (const element of getElements<HTMLInputElement>('input[type="checkbox"][name="pk"]')) {
element.addEventListener('change', handlePkCheck);
}
const selectAll = getElement<HTMLInputElement>('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<HTMLSelectElement>('select.per-page')) {
element.addEventListener('change', handlePerPageSelect);
}
}
export function initButtons(): void {
for (const func of [
initDepthToggle,
initConnectionToggle,
initReslug,
initSelectAll,
initPreferenceUpdate,
initPerPage,
]) {
func();
}
}

View File

@ -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<HTMLButtonElement>('button.cable-toggle')) {
element.addEventListener('click', () => toggleConnection(element));
}
}

View File

@ -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<HTMLDivElement>('.record-depth')) {
element.style.display = '';
}
}
/**
* Hide all depth indicators.
*/
function hideDepthIndicators(): void {
for (const element of getElements<HTMLDivElement>('.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<ObjectDepthState>, 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<HTMLButtonElement>('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();
}
}

View File

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

View File

@ -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<HTMLButtonElement>('#move-option-up')) {
const target = button.getAttribute('data-target');
if (target !== null) {
for (const select of getElements<HTMLSelectElement>(`#${target}`)) {
button.addEventListener('click', () => moveOptionUp(select));
}
}
}
for (const button of getElements<HTMLButtonElement>('#move-option-down')) {
const target = button.getAttribute('data-target');
if (target !== null) {
for (const select of getElements<HTMLSelectElement>(`#${target}`)) {
button.addEventListener('click', () => moveOptionDown(select));
}
}
}
}

View File

@ -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<HTMLSelectElement>('select.per-page')) {
element.addEventListener('change', handlePerPageSelect);
}
}

View File

@ -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<HTMLFormElement>('preferences-update');
if (form !== null) {
form.addEventListener('submit', handlePreferenceSave);
}
}

View File

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

View File

@ -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<HTMLInputElement>(
'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<HTMLInputElement>(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<HTMLInputElement>;
if (table !== null) {
for (const element of table.querySelectorAll<HTMLInputElement>(
'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<HTMLDivElement>('select-all-box');
if (selectAllBox !== null) {
for (const button of selectAllBox.querySelectorAll<HTMLButtonElement>(
'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<HTMLInputElement>(
'table tr th > input[type="checkbox"].toggle',
)) {
element.addEventListener('change', handleSelectAllToggle);
}
for (const element of getElements<HTMLInputElement>('input[type="checkbox"][name="pk"]')) {
element.addEventListener('change', handlePkCheck);
}
const selectAll = getElement<HTMLInputElement>('select-all');
if (selectAll !== null) {
selectAll.addEventListener('change', handleSelectAll);
}
}

View File

@ -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<HTMLFormElement>(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<HTMLAnchorElement>('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<HTMLAnchorElement>('a.set_speed')) {
if (element !== null) {
function handleClick(event: Event) {
// Don't reload the page (due to href="#").
event.preventDefault();
// Get the value of the `data` attribute on the dropdown option.
const value = element.getAttribute('data');
// Find the input element referenced by the dropdown element.
const input = document.getElementById(element.target) as Nullable<HTMLInputElement>;
if (input !== null && value !== null) {
// Set the value of the input field to the `data` attribute's value.
input.value = value;
}
}
element.addEventListener('click', handleClick);
}
}
}
function handleFormSubmit(event: Event, form: HTMLFormElement): void {
// Track the names of each invalid field.
const invalids = new Set<string>();
for (const element of form.querySelectorAll<FormControls>('*[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<HTMLButtonElement>('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<HTMLButtonElement>('#move-option-up')) {
const target = button.getAttribute('data-target');
if (target !== null) {
for (const select of getElements<HTMLSelectElement>(`#${target}`)) {
button.addEventListener('click', () => moveOptionUp(select));
}
}
}
for (const button of getElements<HTMLButtonElement>('#move-option-down')) {
const target = button.getAttribute('data-target');
if (target !== null) {
for (const select of getElements<HTMLSelectElement>(`#${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<HTMLSelectElement>('#id_scope_type')) {
element.addEventListener('change', handleScopeChange);
}
}
export function initForms(): void {
for (const func of [
initFormElements,
initFormActions,
initMoveButtons,
initSpeedSelector,
initScopeSelector,
]) {
func();
}
}

View File

@ -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<HTMLFormElement>(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<HTMLAnchorElement>('a.formaction')) {
element.addEventListener('click', handleFormActionClick);
}
}

View File

@ -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<string>();
for (const element of form.querySelectorAll<FormControls>('*[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<HTMLButtonElement>('button[type=submit]');
for (const submitter of submitters) {
// Add the event listener to each submitter.
submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form));
}
}
}

View File

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

View File

@ -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<HTMLSelectElement>('#id_scope_type')) {
element.addEventListener('change', handleScopeChange);
}
}

View File

@ -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<HTMLAnchorElement>('a.set_speed')) {
if (element !== null) {
function handleClick(event: Event) {
// Don't reload the page (due to href="#").
event.preventDefault();
// Get the value of the `data` attribute on the dropdown option.
const value = element.getAttribute('data');
// Find the input element referenced by the dropdown element.
const input = document.getElementById(element.target) as Nullable<HTMLInputElement>;
if (input !== null && value !== null) {
// Set the value of the input field to the `data` attribute's value.
input.value = value;
}
}
element.addEventListener('click', handleClick);
}
}
}

View File

@ -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<HTMLSelectElement>): Nullable<HTMLElement> {
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<HTMLSelectElement>('id_tagged_vlans'),
getElement<HTMLSelectElement>('id_untagged_vlan'),
getElement<HTMLSelectElement>('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<HTMLSelectElement>('id_tagged_vlans'),
getElement<HTMLSelectElement>('id_untagged_vlan'),
getElement<HTMLSelectElement>('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<HTMLSelectElement>('id_tagged_vlans'),
getElement<HTMLSelectElement>('id_untagged_vlan'),
getElement<HTMLSelectElement>('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<HTMLSelectElement>('id_tagged_vlans'),
getElement<HTMLSelectElement>('id_untagged_vlan'),
getElement<HTMLSelectElement>('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<HTMLSelectElement>('id_mode');
if (element !== null) {
element.addEventListener('change', () => handleModeChange(element));
handleModeChange(element);
}
}

View File

@ -26,22 +26,6 @@ export function hasMore(data: APIAnswer<APIObjectBase>): 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<V extends unknown>(value: V): value is NonNullable<V> {
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<HTMLElement>.
* }
* ```
*/
export function all<T extends unknown>(values: T[]): values is NonNullable<T>[] {
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<HTMLSelectElement>("select.example");
* select.value = "test";
* console.log(select.value);
* // test
* resetSelect(select);
* console.log(select.value);
* // ''
* ```
*/
export function resetSelect<S extends HTMLSelectElement>(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<E extends HTMLElement | SVGElement>(
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<HTMLDivElement>('div.card-overlay')) {
if (action === 'show') {
element.classList.remove('d-none');
} else {
element.classList.add('d-none');
}
toggleVisibility(element, action);
}
}