Răsfoiți Sursa

add javascript

checktheroads 5 ani în urmă
părinte
comite
912cd220cc

+ 18 - 0
netbox/project-static/src/dateSelector.ts

@@ -0,0 +1,18 @@
+import flatpickr from 'flatpickr';
+
+export function initDateSelector(): void {
+  flatpickr('.date-picker', { allowInput: true });
+  flatpickr('.datetime-picker', {
+    allowInput: true,
+    enableSeconds: true,
+    enableTime: true,
+    time_24hr: true,
+  });
+  flatpickr('.time-picker', {
+    allowInput: true,
+    enableSeconds: true,
+    enableTime: true,
+    noCalendar: true,
+    time_24hr: true,
+  });
+}

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

@@ -0,0 +1,97 @@
+import { getElements, scrollTo } from './util';
+
+/**
+ * Get form data from a form element and transform it into a body usable by fetch.
+ *
+ * @param element Form element
+ * @returns Fetch body
+ */
+export function getFormData(element: HTMLFormElement): URLSearchParams {
+  const formData = new FormData(element);
+  const body = new URLSearchParams();
+  for (const [k, v] of formData) {
+    body.append(k, v as string);
+  }
+  return body;
+}
+
+/**
+ * Set the value of the number input field based on the selection of the dropdown.
+ */
+export function initSpeedSelector(): void {
+  for (const element of getElements<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);
+    }
+  }
+}
+
+/**
+ * Attach an event listener to each form's submitter (button[type=submit]). When called, the
+ * callback checks the validity of each form field and adds the appropriate Bootstrap CSS class
+ * based on the field's validity.
+ */
+export function initForms() {
+  for (const form of getElements('form')) {
+    const { elements } = form;
+    // Find each of the form's submitters. Most object edit forms have a "Create" and
+    // a "Create & Add", so we need to add a listener to both.
+    const submitters = form.querySelectorAll('button[type=submit]');
+
+    function callback(event: Event): void {
+      // Track the names of each invalid field.
+      const invalids = new Set<string>();
+
+      for (const el of elements) {
+        const element = (el as unknown) as FormControls;
+
+        if (!element.validity.valid) {
+          invalids.add(element.name);
+
+          // If the field is invalid, but contains the .is-valid class, remove it.
+          if (element.classList.contains('is-valid')) {
+            element.classList.remove('is-valid');
+          }
+          // If the field is invalid, but doesn't contain the .is-invalid class, add it.
+          if (!element.classList.contains('is-invalid')) {
+            element.classList.add('is-invalid');
+          }
+        } else {
+          // If the field is valid, but contains the .is-invalid class, remove it.
+          if (element.classList.contains('is-invalid')) {
+            element.classList.remove('is-invalid');
+          }
+          // If the field is valid, but doesn't contain the .is-valid class, add it.
+          if (!element.classList.contains('is-valid')) {
+            element.classList.add('is-valid');
+          }
+        }
+      }
+
+      if (invalids.size !== 0) {
+        // If there are invalid fields, pick the first field and scroll to it.
+        const firstInvalid = elements.namedItem(Array.from(invalids)[0]) as Element;
+        scrollTo(firstInvalid);
+
+        // If the form has invalid fields, don't submit it.
+        event.preventDefault();
+      }
+    }
+    for (const submitter of submitters) {
+      // Add the event listener to each submitter.
+      submitter.addEventListener('click', callback);
+    }
+  }
+}

+ 38 - 0
netbox/project-static/src/global.d.ts

@@ -0,0 +1,38 @@
+type Nullable<T> = T | null;
+
+type APIAnswer<T> = {
+  count: number;
+  next: Nullable<string>;
+  previous: Nullable<string>;
+  results: T[];
+};
+
+type APIError = {
+  error: string;
+  exception: string;
+  netbox_version: string;
+  python_version: string;
+};
+
+type APIObjectBase = {
+  id: number;
+  name: string;
+  url: string;
+  [k: string]: unknown;
+};
+
+interface APIReference {
+  id: number;
+  name: string;
+  slug: string;
+  url: string;
+  _depth: number;
+}
+
+interface ObjectWithGroup extends APIObjectBase {
+  group: Nullable<APIReference>;
+}
+
+declare const messages: string[];
+
+type FormControls = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;

+ 13 - 0
netbox/project-static/src/index.ts

@@ -0,0 +1,13 @@
+// const jquery = require("jquery");
+/* // @ts-expect-error */
+// window.$ = window.jQuery = jquery;
+// require("jquery-ui");
+// require("select2");
+// require("./js/forms");
+
+require('babel-polyfill');
+require('@popperjs/core');
+require('bootstrap');
+require('clipboard');
+require('flatpickr');
+require('./netbox');

+ 101 - 0
netbox/project-static/src/netbox.ts

@@ -0,0 +1,101 @@
+import { Tooltip } from 'bootstrap';
+import Masonry from 'masonry-layout';
+import { initApiSelect, initStaticSelect, initColorSelect } from './select';
+import { initDateSelector } from './dateSelector';
+import { initMessageToasts } from './toast';
+import { initSpeedSelector, initForms } from './forms';
+import { initSearchBar } from './search';
+
+const INITIALIZERS = [
+  initSearchBar,
+  initMasonry,
+  bindReslug,
+  initApiSelect,
+  initStaticSelect,
+  initDateSelector,
+  initSpeedSelector,
+  initColorSelect,
+] as (() => void)[];
+
+/**
+ * Enable Tooltips everywhere
+ * @see https://getbootstrap.com/docs/5.0/components/tooltips/
+ */
+function initBootstrap(): void {
+  if (document !== null) {
+    const tooltips = Array.from(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
+    for (const tooltip of tooltips) {
+      new Tooltip(tooltip, { container: 'body', boundary: 'window' });
+    }
+    initMessageToasts();
+    initForms();
+  }
+}
+
+function initMasonry() {
+  if (document !== null) {
+    const grids = document.querySelectorAll('.masonry');
+    for (const grid of grids) {
+      new Masonry(grid, {
+        itemSelector: '.masonry-item',
+        percentPosition: true,
+      });
+    }
+  }
+}
+
+/**
+ * Create a slug from any input string.
+ * @param slug Original string.
+ * @param chars Maximum number of characters.
+ * @returns Slugified string.
+ */
+function slugify(slug: string, chars: number): string {
+  return slug
+    .replace(/[^\-\.\w\s]/g, '') // Remove unneeded chars
+    .replace(/^[\s\.]+|[\s\.]+$/g, '') // Trim leading/trailing spaces
+    .replace(/[\-\.\s]+/g, '-') // Convert spaces and decimals to hyphens
+    .toLowerCase() // Convert to lowercase
+    .substring(0, chars); // Trim to first chars chars
+}
+
+/**
+ * If a slug field exists, add event listeners to handle automatically generating its value.
+ */
+function bindReslug(): void {
+  const slugField = document.getElementById('id_slug') as HTMLInputElement;
+  const slugButton = document.getElementById('reslug') as HTMLButtonElement;
+  if (slugField === null || slugButton === null) {
+    return;
+  }
+  const sourceId = slugField.getAttribute('slug-source');
+  const sourceField = document.getElementById(`id_${sourceId}`) as HTMLInputElement;
+
+  if (sourceField === null) {
+    console.error('Unable to find field for slug field.');
+    return;
+  }
+
+  const slugLengthAttr = slugField.getAttribute('maxlength');
+  let slugLength = 50;
+
+  if (slugLengthAttr) {
+    slugLength = Number(slugLengthAttr);
+  }
+  sourceField.addEventListener('blur', () => {
+    slugField.value = slugify(sourceField.value, slugLength);
+  });
+  slugButton.addEventListener('click', () => {
+    slugField.value = slugify(sourceField.value, slugLength);
+  });
+}
+
+if (document.readyState !== 'loading') {
+  initBootstrap();
+} else {
+  document.addEventListener('DOMContentLoaded', initBootstrap);
+}
+
+for (const init of INITIALIZERS) {
+  init();
+}

+ 37 - 0
netbox/project-static/src/search.ts

@@ -0,0 +1,37 @@
+interface SearchFilterButton extends EventTarget {
+  dataset: { searchValue: string };
+}
+
+function isSearchButton(el: any): el is SearchFilterButton {
+  return el?.dataset?.searchValue ?? null !== null;
+}
+
+export function initSearchBar() {
+  const dropdown = document.getElementById('object-type-selector');
+  const selectedValue = document.getElementById('selected-value') as HTMLSpanElement;
+  const selectedType = document.getElementById('search-obj-type') as HTMLInputElement;
+  let selected = '';
+
+  if (dropdown !== null) {
+    const buttons = dropdown.querySelectorAll('li > button.dropdown-item');
+    for (const button of buttons) {
+      if (button !== null) {
+        function handleClick(event: Event) {
+          if (isSearchButton(event.target)) {
+            const objectType = event.target.dataset.searchValue;
+            if (objectType !== '' && selected !== objectType) {
+              selected = objectType;
+              selectedValue.innerHTML = button.textContent ?? 'Error';
+              selectedType.value = objectType;
+            } else {
+              selected = '';
+              selectedType.innerHTML = 'All Objects';
+              selectedType.value = '';
+            }
+          }
+        }
+        button.addEventListener('click', handleClick);
+      }
+    }
+  }
+}

+ 167 - 0
netbox/project-static/src/select-choices/api.ts

@@ -0,0 +1,167 @@
+import Choices from "choices.js";
+import queryString from "query-string";
+import { getApiData, isApiError } from "../util";
+import { createToast } from "../toast";
+
+import type { Choices as TChoices } from "choices.js";
+
+interface CustomSelect extends HTMLSelectElement {
+  dataset: {
+    url: string;
+  };
+}
+
+function isCustomSelect(el: HTMLSelectElement): el is CustomSelect {
+  return typeof el?.dataset?.url === "string";
+}
+
+/**
+ * Determine if a select element should be filtered by the value of another select element.
+ *
+ * Looks for the DOM attribute `data-query-param-<name of other field>`, which would look like:
+ * `["$<name>"]`
+ *
+ * If the attribute exists, parse out the raw value. In the above example, this would be `name`.
+ * @param element Element to scan
+ * @returns Attribute name, or null if it was not found.
+ */
+function getFilteredBy<T extends HTMLElement>(element: T): string[] {
+  const pattern = new RegExp(/\[|\]|"|\$/g);
+  const keys = Object.keys(element.dataset);
+  const filteredBy = [] as string[];
+  for (const key of keys) {
+    if (key.includes("queryParam")) {
+      const value = element.dataset[key];
+      if (typeof value !== "undefined") {
+        const parsed = JSON.parse(value) as string | string[];
+        if (Array.isArray(parsed)) {
+          filteredBy.push(parsed[0].replaceAll(pattern, ""));
+        } else {
+          filteredBy.push(value.replaceAll(pattern, ""));
+        }
+      }
+    }
+    if (key === "url" && element.dataset.url?.includes(`{{`)) {
+      /**
+       * If the URL contains a Django/Jinja template variable tag we need to extract the variable
+       * name and consider this a field to monitor for changes.
+       */
+      const value = element.dataset.url.match(/\{\{(.+)\}\}/);
+      if (value !== null) {
+        filteredBy.push(value[1]);
+      }
+    }
+  }
+  return filteredBy;
+}
+
+export function initApiSelect() {
+  const elements = document.querySelectorAll(
+    ".netbox-select2-api"
+  ) as NodeListOf<HTMLSelectElement>;
+
+  for (const element of elements) {
+    if (isCustomSelect(element)) {
+      let { url } = element.dataset;
+
+      const instance = new Choices(element, {
+        noChoicesText: "No Options Available",
+        itemSelectText: "",
+      });
+
+      /**
+       * Retrieve all objects for this object type.
+       *
+       * @param choiceUrl Optionally override the URL for filtering. If not set, the URL
+       *                  from the DOM attributes is used.
+       * @returns Data parsed into Choices.JS Choices.
+       */
+      async function getChoices(
+        choiceUrl: string = url
+      ): Promise<TChoices.Choice[]> {
+        if (choiceUrl.includes(`{{`)) {
+          return [];
+        }
+        return getApiData(choiceUrl).then((data) => {
+          if (isApiError(data)) {
+            const toast = createToast("danger", data.exception, data.error);
+            toast.show();
+            return [];
+          }
+          const { results } = data;
+          const options = [] as TChoices.Choice[];
+
+          if (results.length !== 0) {
+            for (const result of results) {
+              const choice = {
+                value: result.id.toString(),
+                label: result.name,
+              } as TChoices.Choice;
+              options.push(choice);
+            }
+          }
+          return options;
+        });
+      }
+
+      const filteredBy = getFilteredBy(element);
+
+      if (filteredBy.length !== 0) {
+        for (const filter of filteredBy) {
+          // Find element with the `name` attribute matching this element's filtered-by attribute.
+          const groupElem = document.querySelector(
+            `[name=${filter}]`
+          ) as HTMLSelectElement;
+
+          if (groupElem !== null) {
+            /**
+             * When the group's selection changes, re-query the dependant element's options, but
+             * filtered to results matching the group's ID.
+             *
+             * @param event Group's DOM event.
+             */
+            function handleEvent(event: Event) {
+              let filterUrl: string | undefined;
+
+              const target = event.target as HTMLSelectElement;
+
+              if (target.value) {
+                if (url.includes(`{{`)) {
+                  /**
+                   * If the URL contains a Django/Jinja template variable tag, we need to replace
+                   * the tag with the event's value.
+                   */
+                  url = url.replaceAll(/\{\{(.+)\}\}/g, target.value);
+                  element.setAttribute("data-url", url);
+                }
+                let queryKey = filter;
+                if (filter?.includes("_group")) {
+                  /**
+                   * For example, a tenant's group relationship field is `group`, but the field
+                   * name is `tenant_group`.
+                   */
+                  queryKey = "group";
+                }
+                filterUrl = queryString.stringifyUrl({
+                  url,
+                  query: { [`${queryKey}_id`]: groupElem.value },
+                });
+              }
+
+              instance.setChoices(
+                () => getChoices(filterUrl),
+                undefined,
+                undefined,
+                true
+              );
+            }
+            groupElem.addEventListener("addItem", handleEvent);
+            groupElem.addEventListener("removeItem", handleEvent);
+          }
+        }
+      }
+
+      instance.setChoices(() => getChoices());
+    }
+  }
+}

+ 2 - 0
netbox/project-static/src/select-choices/index.ts

@@ -0,0 +1,2 @@
+export * from "./api";
+export * from "./static";

+ 16 - 0
netbox/project-static/src/select-choices/static.ts

@@ -0,0 +1,16 @@
+import Choices from "choices.js";
+
+export function initStaticSelect() {
+  const elements = document.querySelectorAll(
+    ".netbox-select2-static"
+  ) as NodeListOf<HTMLSelectElement>;
+
+  for (const element of elements) {
+    if (element !== null) {
+      new Choices(element, {
+        noChoicesText: "No Options Available",
+        itemSelectText: "",
+      });
+    }
+  }
+}

+ 216 - 0
netbox/project-static/src/select/api.ts

@@ -0,0 +1,216 @@
+import SlimSelect from 'slim-select';
+import queryString from 'query-string';
+import { getApiData, isApiError } from '../util';
+import { createToast } from '../toast';
+import { setOptionStyles, getFilteredBy } from './util';
+
+import type { Option } from 'slim-select/dist/data';
+
+type WithUrl = {
+  url: string;
+};
+
+type WithExclude = {
+  queryParamExclude: string;
+};
+
+interface CustomSelect<T extends Record<string, string>> extends HTMLSelectElement {
+  dataset: T;
+}
+
+function isCustomSelect(el: HTMLSelectElement): el is CustomSelect<WithUrl> {
+  return typeof el?.dataset?.url === 'string';
+}
+
+function hasExclusions(el: HTMLSelectElement): el is CustomSelect<WithExclude> {
+  return (
+    typeof el?.dataset?.queryParamExclude === 'string' && el?.dataset?.queryParamExclude !== ''
+  );
+}
+
+const PLACEHOLDER = {
+  value: '',
+  text: '',
+  placeholder: true,
+} as Option;
+
+export function initApiSelect() {
+  const elements = document.querySelectorAll<HTMLSelectElement>('.netbox-select2-api');
+  for (const select of elements) {
+    // Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
+    // existing object. When we fetch options from the API later, we can set any of the options
+    // contained in this array to `selected`.
+    const selectOptions = Array.from(select.options)
+      .filter(option => option.value !== '')
+      .map(option => option.value);
+
+    const filteredBy = getFilteredBy(select);
+    const filterMap = new Map<string, string>();
+
+    if (isCustomSelect(select)) {
+      let { url } = select.dataset;
+      const displayField = select.getAttribute('display-field') ?? 'name';
+
+      // Set the placeholder text to the label value, if it exists.
+      let placeholder;
+      if (select.id) {
+        const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
+
+        if (label !== null) {
+          placeholder = `Select ${label.innerText.trim()}`;
+        }
+      }
+      let disabledOptions = [] as string[];
+      if (hasExclusions(select)) {
+        disabledOptions = JSON.parse(select.dataset.queryParamExclude) as string[];
+      }
+
+      const instance = new SlimSelect({
+        select,
+        allowDeselect: true,
+        deselectLabel: `<i class="bi bi-x-circle" style="color:currentColor;"></i>`,
+        placeholder,
+      });
+
+      // Reset validity classes if the field was invalid.
+      instance.onChange = () => {
+        const element = instance.slim.container ?? null;
+        if (element !== null) {
+          if (element.classList.contains('is-invalid') || select.classList.contains('is-invalid')) {
+            select.classList.remove('is-invalid');
+            element.classList.remove('is-invalid');
+          }
+        }
+      };
+
+      // Don't copy classes from select element to SlimSelect instance.
+      for (const className of select.classList) {
+        instance.slim.container.classList.remove(className);
+      }
+
+      // Set the underlying select element to the same size as the SlimSelect instance.
+      // This is primarily for built-in HTML form validation, which doesn't really work,
+      // but it also makes things seem cleaner in the DOM.
+      const { width, height } = instance.slim.container.getBoundingClientRect();
+      select.style.opacity = '0';
+      select.style.width = `${width}px`;
+      select.style.height = `${height}px`;
+      select.style.display = 'block';
+      select.style.position = 'absolute';
+      select.style.pointerEvents = 'none';
+
+      /**
+       * Retrieve all objects for this object type.
+       *
+       * @param choiceUrl Optionally override the URL for filtering. If not set, the URL
+       *                  from the DOM attributes is used.
+       * @returns Data parsed into Choices.JS Choices.
+       */
+      async function getChoices(choiceUrl: string = url): Promise<Option[]> {
+        if (choiceUrl.includes(`{{`)) {
+          return [PLACEHOLDER];
+        }
+        return getApiData(choiceUrl).then(data => {
+          if (isApiError(data)) {
+            const toast = createToast('danger', data.exception, data.error);
+            toast.show();
+            return [PLACEHOLDER];
+          }
+
+          const { results } = data;
+          const options = [PLACEHOLDER] as Option[];
+
+          if (results.length !== 0) {
+            for (const result of results) {
+              const data = {} as Record<string, string>;
+              const value = result.id.toString();
+              let style, selected;
+              for (const [k, v] of Object.entries(result)) {
+                if (
+                  !['id', 'slug'].includes(k) &&
+                  ['string', 'number', 'boolean'].includes(typeof v)
+                ) {
+                  const key = k.replaceAll('_', '-');
+                  data[key] = String(v);
+                }
+              }
+              if (selectOptions.includes(value)) {
+                selected = true;
+              }
+
+              const choice = {
+                value,
+                text: result[displayField],
+                data,
+                style,
+                selected,
+              } as Option;
+
+              options.push(choice);
+            }
+          }
+          return options;
+        });
+      }
+
+      if (filteredBy.length !== 0) {
+        for (const filter of filteredBy) {
+          // Find element with the `name` attribute matching this element's filtered-by attribute.
+          const groupElem = document.querySelector(`[name=${filter}]`) as HTMLSelectElement;
+
+          if (groupElem !== null) {
+            // Add the group's value to the filtered-by map.
+            filterMap.set(filter, groupElem.value);
+            // If the URL contains a Django/Jinja template variable tag, we need to replace the tag
+            // with the event's value.
+            if (url.includes(`{{`)) {
+              url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), groupElem.value);
+              select.setAttribute('data-url', url);
+            }
+            /**
+             * When the group's selection changes, re-query the dependant element's options, but
+             * filtered to results matching the group's ID.
+             *
+             * @param event Group's DOM event.
+             */
+            function handleEvent(event: Event) {
+              let filterUrl: string | undefined;
+
+              const target = event.target as HTMLSelectElement;
+
+              if (target.value) {
+                let filterValue = filterMap.get(target.value);
+                if (url.includes(`{{`) && typeof filterValue !== 'undefined') {
+                  // If the URL contains a Django/Jinja template variable tag, we need to replace
+                  // the tag with the event's value.
+                  url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), filterValue);
+                  select.setAttribute('data-url', url);
+                }
+
+                let queryKey = filterValue;
+                if (filter?.includes('_group')) {
+                  // For example, a tenant's group relationship field is `group`, but the field
+                  // name is `tenant_group`.
+                  queryKey = 'group';
+                }
+                filterUrl = queryString.stringifyUrl({
+                  url,
+                  query: { [`${queryKey}_id`]: groupElem.value },
+                });
+              }
+
+              getChoices(filterUrl).then(data => instance.setData(data));
+            }
+
+            groupElem.addEventListener('change', handleEvent);
+            groupElem.addEventListener('change', handleEvent);
+          }
+        }
+      }
+
+      getChoices()
+        .then(data => instance.setData(data))
+        .finally(() => setOptionStyles(instance));
+    }
+  }
+}

+ 82 - 0
netbox/project-static/src/select/color.ts

@@ -0,0 +1,82 @@
+import SlimSelect from 'slim-select';
+import { readableColor } from 'color2k';
+import { getElements } from '../util';
+
+import type { Option } from 'slim-select/dist/data';
+
+/**
+ * Determine if the option has a valid value (i.e., is not the placeholder).
+ */
+function canChangeColor(option: Option | HTMLOptionElement): boolean {
+  return typeof option.value === 'string' && option.value !== '';
+}
+
+/**
+ * Initialize color selection widget. Dynamically change the style of the select container to match
+ * the selected option.
+ */
+export function initColorSelect(): void {
+  for (const select of getElements<HTMLSelectElement>('select.netbox-select2-color-picker')) {
+    for (const option of select.options) {
+      if (canChangeColor(option)) {
+        // Get the background color from the option's value.
+        const bg = `#${option.value}`;
+        // Determine an accessible foreground color based on the background color.
+        const fg = readableColor(bg);
+
+        // Set the option's style attributes.
+        option.style.backgroundColor = bg;
+        option.style.color = fg;
+      }
+    }
+
+    const instance = new SlimSelect({
+      select,
+      allowDeselect: true,
+      // Inherit the calculated color on the deselect icon.
+      deselectLabel: `<i class="bi bi-x-circle" style="color: currentColor;"></i>`,
+    });
+
+    // Style the select container to match any pre-selectd options.
+    for (const option of instance.data.data) {
+      if ('selected' in option && option.selected) {
+        styleContainer(option);
+        break;
+      }
+    }
+
+    // Don't inherit the select element's classes.
+    for (const className of select.classList) {
+      instance.slim.container.classList.remove(className);
+    }
+
+    function styleContainer(option: Option | HTMLOptionElement): void {
+      if (instance.slim.singleSelected !== null) {
+        if (canChangeColor(option)) {
+          // Get the background color from the selected option's value.
+          const bg = `#${option.value}`;
+          // Determine an accessible foreground color based on the background color.
+          const fg = readableColor(bg);
+
+          // Set the container's style attributes.
+          instance.slim.singleSelected.container.style.backgroundColor = bg;
+          instance.slim.singleSelected.container.style.color = fg;
+
+          // Find this element's label.
+          const label = document.querySelector<HTMLLabelElement>(`label[for=${select.id}]`);
+
+          if (label !== null) {
+            // Set the field's label color to match (Bootstrap sets the opacity to 0.65 as well).
+            label.style.color = fg;
+          }
+        } else {
+          // If the color cannot be set (i.e., the placeholder), remove any inline styles.
+          instance.slim.singleSelected.container.removeAttribute('style');
+        }
+      }
+    }
+
+    // Change the SlimSelect container's style based on the selected option.
+    instance.onChange = styleContainer;
+  }
+}

+ 3 - 0
netbox/project-static/src/select/index.ts

@@ -0,0 +1,3 @@
+export * from './api';
+export * from './static';
+export * from './color';

+ 23 - 0
netbox/project-static/src/select/static.ts

@@ -0,0 +1,23 @@
+import SlimSelect from 'slim-select';
+
+export function initStaticSelect() {
+  const elements = document.querySelectorAll(
+    '.netbox-select2-static',
+  ) as NodeListOf<HTMLSelectElement>;
+
+  for (const select of elements) {
+    if (select !== null) {
+      const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
+      let placeholder;
+      if (label !== null) {
+        placeholder = `Select ${label.innerText.trim()}`;
+      }
+      new SlimSelect({
+        select,
+        allowDeselect: true,
+        deselectLabel: `<i class="bi bi-x-circle"></i>`,
+        placeholder,
+      });
+    }
+  }
+}

+ 99 - 0
netbox/project-static/src/select/util.ts

@@ -0,0 +1,99 @@
+import { readableColor } from 'color2k';
+
+import type SlimSelect from 'slim-select';
+
+/**
+ * Add scoped style elements specific to each SlimSelect option, if the color property exists.
+ * As of this writing, this attribute only exist on Tags. The color property is used as the
+ * background color, and a foreground color is detected based on the luminosity of the background
+ * color.
+ *
+ * @param instance SlimSelect instance with options already set.
+ */
+export function setOptionStyles(instance: SlimSelect): void {
+  const options = instance.data.data;
+  for (const option of options) {
+    // Only create style elements for options that contain a color attribute.
+    if (
+      'data' in option &&
+      'id' in option &&
+      typeof option.data !== 'undefined' &&
+      typeof option.id !== 'undefined' &&
+      'color' in option.data
+    ) {
+      const id = option.id as string;
+      const data = option.data as { color: string };
+
+      // Create the style element.
+      const style = document.createElement('style');
+
+      // Append hash to color to make it a valid hex color.
+      const bg = `#${data.color}`;
+      // Detect the foreground color.
+      const fg = readableColor(bg);
+
+      // Add a unique identifier to the style element.
+      style.dataset.netbox = id;
+
+      // Scope the CSS to apply both the list item and the selected item.
+      style.innerHTML = `
+div.ss-values div.ss-value[data-id="${id}"],
+div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
+ {
+  background-color: ${bg} !important;
+  color: ${fg} !important;
+}
+            `
+        .replaceAll('\n', '')
+        .trim();
+
+      // Add the style element to the DOM.
+      document.head.appendChild(style);
+    }
+  }
+}
+
+/**
+ * Determine if a select element should be filtered by the value of another select element.
+ *
+ * Looks for the DOM attribute `data-query-param-<name of other field>`, which would look like:
+ * `["$<name>"]`
+ *
+ * If the attribute exists, parse out the raw value. In the above example, this would be `name`.
+ *
+ * @param element Element to scan
+ * @returns Attribute name, or null if it was not found.
+ */
+export function getFilteredBy<T extends HTMLElement>(element: T): string[] {
+  const pattern = new RegExp(/\[|\]|"|\$/g);
+  const keys = Object.keys(element.dataset);
+  const filteredBy = [] as string[];
+
+  // Process the URL attribute in a separate loop so that it comes first.
+  for (const key of keys) {
+    if (key === 'url' && element.dataset.url?.includes(`{{`)) {
+      /**
+       * If the URL contains a Django/Jinja template variable tag we need to extract the variable
+       * name and consider this a field to monitor for changes.
+       */
+      const value = element.dataset.url.match(/\{\{(.+)\}\}/);
+      if (value !== null) {
+        filteredBy.push(value[1]);
+      }
+    }
+  }
+  for (const key of keys) {
+    if (key.includes('queryParam') && key !== 'queryParamExclude') {
+      const value = element.dataset[key];
+      if (typeof value !== 'undefined') {
+        const parsed = JSON.parse(value) as string | string[];
+        if (Array.isArray(parsed)) {
+          filteredBy.push(parsed[0].replaceAll(pattern, ''));
+        } else {
+          filteredBy.push(value.replaceAll(pattern, ''));
+        }
+      }
+    }
+  }
+  return filteredBy;
+}

+ 86 - 0
netbox/project-static/src/toast.ts

@@ -0,0 +1,86 @@
+import { Toast } from 'bootstrap';
+
+type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
+
+export function createToast(
+  level: ToastLevel,
+  title: string,
+  message: string,
+  extra?: string,
+): Toast {
+  let iconName = 'bi-exclamation-triangle-fill';
+  switch (level) {
+    case 'warning':
+      iconName = 'bi-exclamation-triangle-fill';
+    case 'success':
+      iconName = 'bi-check-circle-fill';
+    case 'info':
+      iconName = 'bi-info-circle-fill';
+    case 'danger':
+      iconName = 'bi-exclamation-triangle-fill';
+  }
+
+  const container = document.createElement('div');
+  container.setAttribute('class', 'toast-container position-fixed bottom-0 end-0 m-3');
+
+  const main = document.createElement('div');
+  main.setAttribute('class', `toast bg-${level}`);
+  main.setAttribute('role', 'alert');
+  main.setAttribute('aria-live', 'assertive');
+  main.setAttribute('aria-atomic', 'true');
+
+  const header = document.createElement('div');
+  header.setAttribute('class', `toast-header bg-${level} text-body`);
+
+  const icon = document.createElement('i');
+  icon.setAttribute('class', `bi ${iconName}`);
+
+  const titleElement = document.createElement('strong');
+  titleElement.setAttribute('class', 'me-auto ms-1');
+  titleElement.innerText = title;
+
+  const button = document.createElement('button');
+  button.setAttribute('type', 'button');
+  button.setAttribute('class', 'btn-close');
+  button.setAttribute('data-bs-dismiss', 'toast');
+  button.setAttribute('aria-label', 'Close');
+
+  const body = document.createElement('div');
+  body.setAttribute('class', 'toast-body');
+
+  header.appendChild(icon);
+  header.appendChild(titleElement);
+
+  if (typeof extra !== 'undefined') {
+    const extraElement = document.createElement('small');
+    extraElement.setAttribute('class', 'text-muted');
+    header.appendChild(extraElement);
+  }
+
+  header.appendChild(button);
+
+  body.innerText = message.trim();
+
+  main.appendChild(header);
+  main.appendChild(body);
+  container.appendChild(main);
+  document.body.appendChild(container);
+
+  const toast = new Toast(main);
+  return toast;
+}
+
+/**
+ * Find any active messages from django.contrib.messages and show them in a toast.
+ */
+export function initMessageToasts(): void {
+  const elements = document.querySelectorAll<HTMLDivElement>(
+    'body > div#django-messages > div.django-message.toast',
+  );
+  for (const element of elements) {
+    if (element !== null) {
+      const toast = new Toast(element);
+      toast.show();
+    }
+  }
+}

+ 71 - 0
netbox/project-static/src/util.ts

@@ -0,0 +1,71 @@
+import Cookie from 'cookie';
+
+export function isApiError(data: Record<string, unknown>): data is APIError {
+  return 'error' in data;
+}
+
+/**
+ * Retrieve the CSRF token from cookie storage.
+ */
+export function getCsrfToken(): string {
+  const { csrftoken: csrfToken } = Cookie.parse(document.cookie);
+  if (typeof csrfToken === 'undefined') {
+    throw new Error('Invalid or missing CSRF token');
+  }
+  return csrfToken;
+}
+
+/**
+ * Fetch data from the NetBox API (authenticated).
+ * @param url API endpoint
+ */
+export async function getApiData<T extends APIObjectBase>(
+  url: string,
+): Promise<APIAnswer<T> | APIError> {
+  const token = getCsrfToken();
+  const res = await fetch(url, {
+    method: 'GET',
+    headers: { 'X-CSRFToken': token },
+  });
+  const json = (await res.json()) as APIAnswer<T> | APIError;
+  return json;
+}
+
+export function getElements<K extends keyof SVGElementTagNameMap>(
+  key: K,
+): Generator<SVGElementTagNameMap[K]>;
+export function getElements<K extends keyof HTMLElementTagNameMap>(
+  key: K,
+): Generator<HTMLElementTagNameMap[K]>;
+export function getElements<E extends Element>(key: string): Generator<E>;
+export function* getElements(
+  key: string | keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap,
+) {
+  for (const element of document.querySelectorAll(key)) {
+    if (element !== null) {
+      yield element;
+    }
+  }
+}
+
+/**
+ * scrollTo() wrapper that calculates a Y offset relative to `element`, but also factors in an
+ * offset relative to div#content-title. This ensures we scroll to the element, but leave enough
+ * room to see said element.
+ *
+ * @param element Element to scroll to
+ * @param offset Y Offset. 0 by default, to take into account the NetBox header.
+ */
+export function scrollTo(element: Element, offset: number = 0): void {
+  let yOffset = offset;
+  const title = document.getElementById('content-title') as Nullable<HTMLDivElement>;
+  if (title !== null) {
+    // If the #content-title element exists, add it to the offset.
+    yOffset += title.getBoundingClientRect().bottom;
+  }
+  // Calculate the scrollTo target.
+  const top = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
+  // Scroll to the calculated location.
+  window.scrollTo({ top, behavior: 'smooth' });
+  return;
+}