Просмотр исходного кода

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

Matt 4 лет назад
Родитель
Сommit
2e90f22529
27 измененных файлов с 826 добавлено и 653 удалено
  1. 0 0
      netbox/project-static/dist/config.js
  2. 0 0
      netbox/project-static/dist/config.js.map
  3. 0 0
      netbox/project-static/dist/jobs.js
  4. 0 0
      netbox/project-static/dist/jobs.js.map
  5. 0 0
      netbox/project-static/dist/lldp.js
  6. 0 0
      netbox/project-static/dist/lldp.js.map
  7. 0 0
      netbox/project-static/dist/netbox.js
  8. 0 0
      netbox/project-static/dist/netbox.js.map
  9. 0 0
      netbox/project-static/dist/status.js
  10. 0 0
      netbox/project-static/dist/status.js.map
  11. 0 329
      netbox/project-static/src/buttons.ts
  12. 52 0
      netbox/project-static/src/buttons/connectionToggle.ts
  13. 79 0
      netbox/project-static/src/buttons/depthToggle.ts
  14. 21 0
      netbox/project-static/src/buttons/index.ts
  15. 61 0
      netbox/project-static/src/buttons/moveOptions.ts
  16. 14 0
      netbox/project-static/src/buttons/pagination.ts
  17. 30 0
      netbox/project-static/src/buttons/preferences.ts
  18. 46 0
      netbox/project-static/src/buttons/reslug.ts
  19. 106 0
      netbox/project-static/src/buttons/selectAll.ts
  20. 0 303
      netbox/project-static/src/forms.ts
  21. 28 0
      netbox/project-static/src/forms/actions.ts
  22. 57 0
      netbox/project-static/src/forms/elements.ts
  23. 17 0
      netbox/project-static/src/forms/index.ts
  24. 109 0
      netbox/project-static/src/forms/scopeSelector.ts
  25. 24 0
      netbox/project-static/src/forms/speedSelector.ts
  26. 116 0
      netbox/project-static/src/forms/vlanTags.ts
  27. 66 21
      netbox/project-static/src/util.ts

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/jobs.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/jobs.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js.map


+ 0 - 329
netbox/project-static/src/buttons.ts

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

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

+ 79 - 0
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<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();
+  }
+}

+ 21 - 0
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();
+  }
+}

+ 61 - 0
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<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));
+      }
+    }
+  }
+}

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

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

+ 46 - 0
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);
+  });
+}

+ 106 - 0
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<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);
+  }
+}

+ 0 - 303
netbox/project-static/src/forms.ts

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

+ 28 - 0
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<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);
+  }
+}

+ 57 - 0
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<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));
+    }
+  }
+}

+ 17 - 0
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();
+  }
+}

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

+ 24 - 0
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<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);
+    }
+  }
+}

+ 116 - 0
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<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);
+  }
+}

+ 66 - 21
netbox/project-static/src/util.ts

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

Некоторые файлы не были показаны из-за большого количества измененных файлов