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

Closes #14917: Replace slim-select with tom-select (#15080)

* Experimenting

* Remove testing resources

* Replace ApiSelect with TomSelect

* Add color support

* Add clear button

* Clear cached options when searching dynamic selects

* Add support for static parameters

* Refactor TomSelect implementation

* Add dynamic parameter support

* Limit number of options to 100

* Remove redundant api_url definitions for user model

* Add support for disabled indicator

* Remove obsolete value-field attr on dynamic select widgets

* Remove obsolete fetch_trigger kwarg from dynamic model choice widgets

* Remove obsolete empty_label kwarg from dynamic model choice widgets

* Add support for API path variables

* Add support for setting a 'null' option

* Annotate depth for recursive hierarchies

* Misc cleanup

* Remove obsolete APISelect code

* Remove slim-select & just-debounce-it

* Clean up type validation

* Closes #14237: Clear child selections on change to parent selection

* Use an MD icon for the clear button

* Use an MD icon for the clear button

* Explain why noUnusedParameters is disabled
Jeremy Stretch 2 лет назад
Родитель
Сommit
d63e1dacbf
32 измененных файлов с 760 добавлено и 1487 удалено
  1. 1 4
      netbox/core/forms/filtersets.py
  2. 6 14
      netbox/dcim/forms/filtersets.py
  3. 3 10
      netbox/extras/forms/filtersets.py
  4. 0 0
      netbox/project-static/dist/netbox.css
  5. 0 0
      netbox/project-static/dist/netbox.js
  6. 0 0
      netbox/project-static/dist/netbox.js.map
  7. 30 0
      netbox/project-static/package-lock.json
  8. 2 2
      netbox/project-static/package.json
  9. 2 2
      netbox/project-static/src/htmx.ts
  10. 1 0
      netbox/project-static/src/index.ts
  11. 2 2
      netbox/project-static/src/netbox.ts
  12. 0 1002
      netbox/project-static/src/select/api/apiSelect.ts
  13. 0 10
      netbox/project-static/src/select/api/index.ts
  14. 0 199
      netbox/project-static/src/select/api/types.ts
  15. 2 2
      netbox/project-static/src/select/classes/dynamicParamsMap.ts
  16. 305 0
      netbox/project-static/src/select/classes/dynamicTomSelect.ts
  17. 0 82
      netbox/project-static/src/select/color.ts
  18. 9 0
      netbox/project-static/src/select/config.ts
  19. 51 0
      netbox/project-static/src/select/dynamic.ts
  20. 6 7
      netbox/project-static/src/select/index.ts
  21. 25 22
      netbox/project-static/src/select/static.ts
  22. 66 0
      netbox/project-static/src/select/types.ts
  23. 0 26
      netbox/project-static/src/select/util.ts
  24. 2 1
      netbox/project-static/styles/netbox.scss
  25. 2 1
      netbox/project-static/tsconfig.json
  26. 237 65
      netbox/project-static/yarn.lock
  27. 1 1
      netbox/templates/django/forms/widgets/select.html
  28. 2 4
      netbox/users/forms/model_forms.py
  29. 1 21
      netbox/utilities/forms/fields/dynamic.py
  30. 2 6
      netbox/utilities/forms/widgets/apiselect.py
  31. 1 2
      netbox/utilities/forms/widgets/select.py
  32. 1 2
      netbox/vpn/forms/model_forms.py

+ 1 - 4
netbox/core/forms/filtersets.py

@@ -119,10 +119,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
     user = DynamicModelMultipleChoiceField(
         queryset=get_user_model().objects.all(),
         required=False,
-        label=_('User'),
-        widget=APISelectMultiple(
-            api_url='/api/users/users/',
-        )
+        label=_('User')
     )
 
 

+ 6 - 14
netbox/dcim/forms/filtersets.py

@@ -393,10 +393,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     user_id = DynamicModelMultipleChoiceField(
         queryset=get_user_model().objects.all(),
         required=False,
-        label=_('User'),
-        widget=APISelectMultiple(
-            api_url='/api/users/users/',
-        )
+        label=_('User')
     )
     tag = TagFilterField(model)
 
@@ -551,8 +548,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         required=False,
-        label=_('Manufacturer'),
-        fetch_trigger='open'
+        label=_('Manufacturer')
     )
     part_number = forms.CharField(
         label=_('Part number'),
@@ -828,8 +824,7 @@ class VirtualDeviceContextFilterForm(
     device = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         required=False,
-        label=_('Device'),
-        fetch_trigger='open'
+        label=_('Device')
     )
     status = forms.MultipleChoiceField(
         label=_('Status'),
@@ -855,8 +850,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         required=False,
-        label=_('Manufacturer'),
-        fetch_trigger='open'
+        label=_('Manufacturer')
     )
     module_type_id = DynamicModelMultipleChoiceField(
         queryset=ModuleType.objects.all(),
@@ -864,8 +858,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
         query_params={
             'manufacturer_id': '$manufacturer_id'
         },
-        label=_('Type'),
-        fetch_trigger='open'
+        label=_('Type')
     )
     status = forms.MultipleChoiceField(
         label=_('Status'),
@@ -1414,8 +1407,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
     role_id = DynamicModelMultipleChoiceField(
         queryset=InventoryItemRole.objects.all(),
         required=False,
-        label=_('Role'),
-        fetch_trigger='open'
+        label=_('Role')
     )
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),

+ 3 - 10
netbox/extras/forms/filtersets.py

@@ -381,8 +381,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
     cluster_type_id = DynamicModelMultipleChoiceField(
         queryset=ClusterType.objects.all(),
         required=False,
-        label=_('Cluster types'),
-        fetch_trigger='open'
+        label=_('Cluster types')
     )
     cluster_group_id = DynamicModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
@@ -462,10 +461,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
     created_by_id = DynamicModelMultipleChoiceField(
         queryset=get_user_model().objects.all(),
         required=False,
-        label=_('User'),
-        widget=APISelectMultiple(
-            api_url='/api/users/users/',
-        )
+        label=_('User')
     )
     assigned_object_type_id = DynamicModelMultipleChoiceField(
         queryset=ContentType.objects.all(),
@@ -508,10 +504,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
     user_id = DynamicModelMultipleChoiceField(
         queryset=get_user_model().objects.all(),
         required=False,
-        label=_('User'),
-        widget=APISelectMultiple(
-            api_url='/api/users/users/',
-        )
+        label=_('User')
     )
     changed_object_type_id = DynamicModelMultipleChoiceField(
         queryset=ContentType.objects.all(),

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


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


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


+ 30 - 0
netbox/project-static/package-lock.json

@@ -25,6 +25,7 @@
         "query-string": "^7.1.1",
         "sass": "^1.55.0",
         "slim-select": "^1.27.1",
+        "tom-select": "^2.3.1",
         "typeface-inter": "^3.18.1",
         "typeface-roboto-mono": "^1.1.13"
       },
@@ -225,6 +226,19 @@
         "node": ">= 8"
       }
     },
+    "node_modules/@orchidjs/sifter": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.0.3.tgz",
+      "integrity": "sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g==",
+      "dependencies": {
+        "@orchidjs/unicode-variants": "^1.0.4"
+      }
+    },
+    "node_modules/@orchidjs/unicode-variants": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz",
+      "integrity": "sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ=="
+    },
     "node_modules/@pkgr/utils": {
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz",
@@ -3888,6 +3902,22 @@
       "integrity": "sha1-bkWxJj8gF/oKzH2J14sVuL932jI=",
       "license": "MIT"
     },
+    "node_modules/tom-select": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.3.1.tgz",
+      "integrity": "sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg==",
+      "dependencies": {
+        "@orchidjs/sifter": "^1.0.3",
+        "@orchidjs/unicode-variants": "^1.0.4"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/tom-select"
+      }
+    },
     "node_modules/tsconfig-paths": {
       "version": "3.14.1",
       "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz",

+ 2 - 2
netbox/project-static/package.json

@@ -31,16 +31,16 @@
     "gridstack": "^7.2.3",
     "html-entities": "^2.3.3",
     "htmx.org": "^1.8.0",
-    "just-debounce-it": "^3.1.1",
     "query-string": "^7.1.1",
     "sass": "^1.55.0",
-    "slim-select": "^1.27.1",
+    "tom-select": "^2.3.1",
     "typeface-inter": "^3.18.1",
     "typeface-roboto-mono": "^1.1.13"
   },
   "devDependencies": {
     "@types/bootstrap": "5.2.10",
     "@types/cookie": "^0.5.1",
+    "@types/node": "^20.11.16",
     "@typescript-eslint/eslint-plugin": "^5.39.0",
     "@typescript-eslint/parser": "^5.39.0",
     "esbuild": "^0.13.15",

+ 2 - 2
netbox/project-static/src/htmx.ts

@@ -1,11 +1,11 @@
 import { getElements, isTruthy } from './util';
 import { initButtons } from './buttons';
-import { initSelect } from './select';
+import { initSelects } from './select';
 import { initObjectSelector } from './objectSelector';
 import { initBootstrap } from './bs';
 
 function initDepedencies(): void {
-  for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) {
+  for (const init of [initButtons, initSelects, initObjectSelector, initBootstrap]) {
     init();
   }
 }

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

@@ -1,4 +1,5 @@
 import '@popperjs/core';
 import 'bootstrap';
 import 'htmx.org';
+import 'tom-select';
 import './netbox';

+ 2 - 2
netbox/project-static/src/netbox.ts

@@ -1,7 +1,7 @@
 import { initForms } from './forms';
 import { initBootstrap } from './bs';
 import { initQuickSearch } from './search';
-import { initSelect } from './select';
+import { initSelects } from './select';
 import { initButtons } from './buttons';
 import { initColorMode } from './colorMode';
 import { initMessages } from './messages';
@@ -22,7 +22,7 @@ function initDocument(): void {
     initMessages,
     initForms,
     initQuickSearch,
-    initSelect,
+    initSelects,
     initDateSelector,
     initButtons,
     initClipboard,

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

@@ -1,1002 +0,0 @@
-import { readableColor } from 'color2k';
-import debounce from 'just-debounce-it';
-import { encode } from 'html-entities';
-import queryString from 'query-string';
-import SlimSelect from 'slim-select';
-import { createToast } from '../../bs';
-import { hasUrl, hasExclusions, isTrigger } from '../util';
-import { DynamicParamsMap } from './dynamicParams';
-import { isStaticParams, isOption } from './types';
-import {
-  hasMore,
-  hasError,
-  isTruthy,
-  getApiData,
-  getElement,
-  isApiError,
-  replaceAll,
-  createElement,
-  uniqueByProperty,
-  findFirstAdjacent,
-} from '../../util';
-
-import type { Stringifiable } from 'query-string';
-import type { Option } from 'slim-select/dist/data';
-import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types';
-
-// Empty placeholder option.
-const EMPTY_PLACEHOLDER = {
-  value: '',
-  text: '',
-  placeholder: true,
-} as Option;
-
-// Attributes which if truthy should render the option disabled.
-const DISABLED_ATTRIBUTES = ['occupied'] as string[];
-
-/**
- * Manage a single API-backed select element's state. Each API select element is likely controlled
- * or dynamically updated by one or more other API select (or static select) elements' values.
- */
-export class APISelect {
-  /**
-   * Base `<select/>` DOM element.
-   */
-  private readonly base: HTMLSelectElement;
-
-  /**
-   * Form field name.
-   */
-  public readonly name: string;
-
-  /**
-   * Form field placeholder.
-   */
-  public readonly placeholder: string;
-
-  /**
-   * Empty/placeholder option. Display text is optionally overridden via the `data-empty-option`
-   * attribute.
-   */
-  public readonly emptyOption: Option;
-
-  /**
-   * Null option. When `data-null-option` attribute is a string, the value is used to created an
-   * option of type `{text: '<value from data-null-option>': 'null'}`.
-   */
-  public readonly nullOption: Nullable<Option> = null;
-
-  /**
-   * Event that will initiate the API call to NetBox to load option data. By default, the trigger
-   * is `'load'`, so data will be fetched when the element renders on the page.
-   */
-  private readonly trigger: Trigger;
-
-  /**
-   * If `true`, a refresh button will be added next to the search/filter `<input/>` element.
-   */
-  private readonly allowRefresh: boolean = true;
-
-  /**
-   * Event to be dispatched when dependent fields' values change.
-   */
-  private readonly loadEvent: InstanceType<typeof Event>;
-
-  /**
-   * Event to be dispatched when the scroll position of this element's optinos list is at the
-   * bottom.
-   */
-  private readonly bottomEvent: InstanceType<typeof Event>;
-
-  /**
-   * SlimSelect instance for this element.
-   */
-  private readonly slim: InstanceType<typeof SlimSelect>;
-
-  /**
-   * Post-parsed URL query parameters for API queries.
-   */
-  private readonly queryParams: QueryFilter = new Map();
-
-  /**
-   * API query parameters that should be applied to API queries for this field. This will be
-   * updated as other dependent fields' values change. This is a mapping of:
-   *
-   *     Form Field Names → Object containing:
-   *                         - Query parameter key name
-   *                         - Query value
-   *
-   * This is different from `queryParams` in that it tracks all _possible_ related fields and their
-   * values, even if they are empty. Further, the keys in `queryParams` correspond to the actual
-   * query parameter keys, which are not necessarily the same as the form field names, depending on
-   * the model. For example, `tenant_group` would be the field name, but `group_id` would be the
-   * query parameter.
-   */
-  private readonly dynamicParams: DynamicParamsMap = new DynamicParamsMap();
-
-  /**
-   * API query parameters that are already known by the server and should not change.
-   */
-  private readonly staticParams: QueryFilter = new Map();
-
-  /**
-   * Mapping of URL template key/value pairs. If this element's URL contains Django template tags
-   * (e.g., `{{key}}`), `key` will be added to `pathValue` and the `id_key` form element will be
-   * tracked for changes. When the `id_key` element's value changes, the new value will be added
-   * to this map. For example, if the template key is `rack`, and the `id_rack` field's value is
-   * `1`, `pathValues` would be updated to reflect a `"rack" => 1` mapping. When the query URL is
-   * updated, the URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
-   */
-  private readonly pathValues: PathFilter = new Map();
-
-  /**
-   * Original API query URL passed via the `data-href` attribute from the server. This is kept so
-   * that the URL can be reconstructed as form values change.
-   */
-  private readonly url: string = '';
-
-  /**
-   * API query URL. This will be updated dynamically to include any query parameters in `queryParameters`.
-   */
-  private queryUrl: string = '';
-
-  /**
-   * Scroll position of options is at the bottom of the list, or not. Used to determine if
-   * additional options should be fetched from the API.
-   */
-  private atBottom: boolean = false;
-
-  /**
-   * API URL for additional options, if applicable. `null` indicates no options remain.
-   */
-  private more: Nullable<string> = null;
-
-  /**
-   * Array of options values which should be considered disabled or static.
-   */
-  private disabledOptions: Array<string> = [];
-
-  /**
-   * Array of properties which if truthy on an API object should be considered disabled.
-   */
-  private disabledAttributes: Array<string> = DISABLED_ATTRIBUTES;
-
-  constructor(base: HTMLSelectElement) {
-    // Initialize readonly properties.
-    this.base = base;
-    this.name = base.name;
-
-    if (hasUrl(base)) {
-      const url = base.getAttribute('data-url') as string;
-      this.url = url;
-      this.queryUrl = url;
-    }
-
-    this.loadEvent = new Event(`netbox.select.onload.${base.name}`);
-    this.bottomEvent = new Event(`netbox.select.atbottom.${base.name}`);
-
-    this.placeholder = this.getPlaceholder();
-    this.disabledOptions = this.getDisabledOptions();
-    this.disabledAttributes = this.getDisabledAttributes();
-
-    const emptyOption = base.getAttribute('data-empty-option');
-    if (isTruthy(emptyOption)) {
-      this.emptyOption = {
-        text: emptyOption,
-        value: '',
-      };
-    } else {
-      this.emptyOption = EMPTY_PLACEHOLDER;
-    }
-
-    const nullOption = base.getAttribute('data-null-option');
-    if (isTruthy(nullOption)) {
-      this.nullOption = {
-        text: nullOption,
-        value: 'null',
-      };
-    }
-
-    this.slim = new SlimSelect({
-      select: this.base,
-      allowDeselect: true,
-      deselectLabel: `<i class="mdi mdi-close-circle" style="color:currentColor;"></i>`,
-      placeholder: this.placeholder,
-      searchPlaceholder: 'Filter',
-      onChange: () => this.handleSlimChange(),
-    });
-
-    // Don't close on select if multiple select
-    if (this.base.multiple) {
-      this.slim.config.closeOnSelect = false;
-    }
-
-    // Initialize API query properties.
-    this.getStaticParams();
-    this.getDynamicParams();
-    this.getPathKeys();
-
-    // Populate static query parameters.
-    for (const [key, value] of this.staticParams.entries()) {
-      this.queryParams.set(key, value);
-    }
-
-    // Populate dynamic query parameters with any form values that are already known.
-    for (const filter of this.dynamicParams.keys()) {
-      this.updateQueryParams(filter);
-    }
-
-    // Populate dynamic path values with any form values that are already known.
-    for (const filter of this.pathValues.keys()) {
-      this.updatePathValues(filter);
-    }
-
-    this.queryParams.set('brief', [true]);
-    this.updateQueryUrl();
-
-    // Initialize element styling.
-    this.resetClasses();
-    this.setSlimStyles();
-
-    // Initialize controlling elements.
-    this.initResetButton();
-
-    // Add the refresh button to the search element.
-    this.initRefreshButton();
-
-    // Add dependency event listeners.
-    this.addEventListeners();
-
-    // Determine if the fetch trigger has been set.
-    const triggerAttr = this.base.getAttribute('data-fetch-trigger');
-
-    // Determine if this element is part of collapsible element.
-    const collapse = this.base.closest('.content-container .collapse');
-
-    if (isTrigger(triggerAttr)) {
-      this.trigger = triggerAttr;
-    } else if (collapse !== null) {
-      this.trigger = 'collapse';
-    } else {
-      this.trigger = 'open';
-    }
-
-    switch (this.trigger) {
-      case 'collapse':
-        if (collapse !== null) {
-          // If the element is collapsible but already shown, load the data immediately.
-          if (collapse.classList.contains('show')) {
-            Promise.all([this.loadData()]);
-          }
-
-          // If this element is part of a collapsible element, only load the data when the
-          // collapsible element is shown.
-          // See: https://getbootstrap.com/docs/5.0/components/collapse/#events
-          collapse.addEventListener('show.bs.collapse', () => this.loadData());
-          collapse.addEventListener('hide.bs.collapse', () => this.resetOptions());
-        }
-        break;
-      case 'open':
-        // If the trigger is 'open', only load API data when the select element is opened.
-        this.slim.beforeOpen = () => this.loadData();
-        break;
-      case 'load':
-        // Otherwise, load the data immediately.
-        Promise.all([this.loadData()]);
-        break;
-    }
-  }
-
-  /**
-   * This instance's available options.
-   */
-  private get options(): Option[] {
-    return this.slim.data.data.filter(isOption);
-  }
-
-  /**
-   * Apply new options to both the SlimSelect instance and this manager's state.
-   */
-  private set options(optionsIn: Option[]) {
-    let newOptions = optionsIn;
-    // Ensure null option is present, if it exists.
-    if (this.nullOption !== null) {
-      newOptions = [this.nullOption, ...newOptions];
-    }
-    // Deduplicate options each time they're set.
-    const deduplicated = uniqueByProperty(newOptions, 'value');
-    // Determine if the new options have a placeholder.
-    const hasPlaceholder = typeof deduplicated.find(o => o.value === '') !== 'undefined';
-    // Get the placeholder index (note: if there is no placeholder, the index will be `-1`).
-    const placeholderIdx = deduplicated.findIndex(o => o.value === '');
-
-    if (hasPlaceholder && placeholderIdx >= 0) {
-      // If there is an existing placeholder, replace it.
-      deduplicated[placeholderIdx] = this.emptyOption;
-    } else {
-      // If there is not a placeholder, add one to the front.
-      deduplicated.unshift(this.emptyOption);
-    }
-    this.slim.setData(deduplicated);
-  }
-
-  /**
-   * Remove all options and reset back to the generic placeholder.
-   */
-  private resetOptions(): void {
-    this.options = [this.emptyOption];
-  }
-
-  /**
-   * Add or remove a class to the SlimSelect element to match Bootstrap .form-select:disabled styles.
-   */
-  public disable(): void {
-    if (this.slim.slim.singleSelected !== null) {
-      if (!this.slim.slim.singleSelected.container.hasAttribute('disabled')) {
-        this.slim.slim.singleSelected.container.setAttribute('disabled', '');
-      }
-    } else if (this.slim.slim.multiSelected !== null) {
-      if (!this.slim.slim.multiSelected.container.hasAttribute('disabled')) {
-        this.slim.slim.multiSelected.container.setAttribute('disabled', '');
-      }
-    }
-    this.slim.disable();
-  }
-
-  /**
-   * Add or remove a class to the SlimSelect element to match Bootstrap .form-select:disabled styles.
-   */
-  public enable(): void {
-    if (this.slim.slim.singleSelected !== null) {
-      if (this.slim.slim.singleSelected.container.hasAttribute('disabled')) {
-        this.slim.slim.singleSelected.container.removeAttribute('disabled');
-      }
-    } else if (this.slim.slim.multiSelected !== null) {
-      if (this.slim.slim.multiSelected.container.hasAttribute('disabled')) {
-        this.slim.slim.multiSelected.container.removeAttribute('disabled');
-      }
-    }
-    this.slim.enable();
-  }
-
-  /**
-   * Add event listeners to this element and its dependencies so that when dependencies change
-   * this element's options are updated.
-   */
-  private addEventListeners(): void {
-    // Create a debounced function to fetch options based on the search input value.
-    const fetcher = debounce((event: Event) => this.handleSearch(event), 300, false);
-
-    // Query the API when the input value changes or a value is pasted.
-    this.slim.slim.search.input.addEventListener('keyup', event => {
-      // Only search when necessary keys are pressed.
-      if (!event.key.match(/^(Arrow|Enter|Tab).*/)) {
-        return fetcher(event);
-      }
-    });
-    this.slim.slim.search.input.addEventListener('paste', event => fetcher(event));
-
-    // Watch every scroll event to determine if the scroll position is at bottom.
-    this.slim.slim.list.addEventListener('scroll', () => this.handleScroll());
-
-    // When the scroll position is at bottom, fetch additional options.
-    this.base.addEventListener(`netbox.select.atbottom.${this.name}`, () =>
-      this.fetchOptions(this.more, 'merge'),
-    );
-
-    // When the base select element is disabled or enabled, properly disable/enable this instance.
-    this.base.addEventListener(`netbox.select.disabled.${this.name}`, event =>
-      this.handleDisableEnable(event),
-    );
-
-    // Create a unique iterator of all possible form fields which, when changed, should cause this
-    // element to update its API query.
-    // const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
-    const dependencies = new Set([...this.dynamicParams.keys(), ...this.pathValues.keys()]);
-
-    for (const dep of dependencies) {
-      const filterElement = document.querySelector(`[name="${dep}"]`);
-      if (filterElement !== null) {
-        // Subscribe to dependency changes.
-        filterElement.addEventListener('change', event => this.handleEvent(event));
-      }
-      // Subscribe to changes dispatched by this state manager.
-      this.base.addEventListener(`netbox.select.onload.${dep}`, event => this.handleEvent(event));
-    }
-  }
-
-  /**
-   * Load this element's options from the NetBox API.
-   */
-  private async loadData(): Promise<void> {
-    try {
-      this.disable();
-      await this.getOptions('replace');
-    } catch (err) {
-      console.error(err);
-    } finally {
-      this.setOptionStyles();
-      this.enable();
-      this.base.dispatchEvent(this.loadEvent);
-    }
-  }
-
-  /**
-   * Get all options from the native select element that are already selected and do not contain
-   * placeholder values.
-   */
-  private getPreselectedOptions(): HTMLOptionElement[] {
-    return Array.from(this.base.options)
-      .filter(option => option.selected)
-      .filter(option => {
-        if (option.value === '---------' || option.innerText === '---------') return false;
-        return true;
-      });
-  }
-
-  /**
-   * Process a valid API response and add results to this instance's options.
-   *
-   * @param data Valid API response (not an error).
-   */
-  private async processOptions(
-    data: APIAnswer<APIObjectBase>,
-    action: ApplyMethod = 'merge',
-  ): Promise<void> {
-    // Get all already-selected options.
-    const preSelected = this.getPreselectedOptions();
-
-    // Get the values of all already-selected options.
-    const selectedValues = preSelected.map(option => option.getAttribute('value')).filter(isTruthy);
-
-    // Build SlimSelect options from all already-selected options.
-    const preSelectedOptions = preSelected.map(option => ({
-      value: option.value,
-      text: encode(option.innerText),
-      selected: true,
-      disabled: false,
-    })) as Option[];
-
-    let options = [] as Option[];
-
-    for (const result of data.results) {
-      let text = encode(result.display);
-
-      if (typeof result._depth === 'number' && result._depth > 0) {
-        // If the object has a `_depth` property, indent its display text.
-        text = `<span class="depth">${'─'.repeat(result._depth)}&nbsp;</span>${text}`;
-      }
-      const data = {} as Record<string, string>;
-      const value = result.id.toString();
-      let style, selected, disabled;
-
-      // Set any primitive k/v pairs as data attributes on each option.
-      for (const [k, v] of Object.entries(result)) {
-        if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
-          const key = replaceAll(k, '_', '-');
-          data[key] = String(v);
-        }
-        // Set option to disabled if the result contains a matching key and is truthy.
-        if (this.disabledAttributes.some(key => key.toLowerCase() === k.toLowerCase())) {
-          if (typeof v === 'string' && v.toLowerCase() !== 'false') {
-            disabled = true;
-          } else if (typeof v === 'boolean' && v === true) {
-            disabled = true;
-          } else if (typeof v === 'number' && v > 0) {
-            disabled = true;
-          }
-        }
-      }
-
-      // Set option to disabled if it is contained within the disabled array.
-      if (selectedValues.some(option => this.disabledOptions.includes(option))) {
-        disabled = true;
-      }
-
-      // Set pre-selected options.
-      if (selectedValues.includes(value)) {
-        selected = true;
-        // If an option is selected, it can't be disabled. Otherwise, it won't be submitted with
-        // the rest of the form, resulting in that field's value being deleting from the object.
-        disabled = false;
-      }
-
-      const option = {
-        value,
-        text,
-        data,
-        style,
-        selected,
-        disabled,
-      } as Option;
-      options = [...options, option];
-    }
-
-    switch (action) {
-      case 'merge':
-        this.options = [...this.options, ...options];
-        break;
-      case 'replace':
-        this.options = [...preSelectedOptions, ...options];
-        break;
-    }
-
-    if (hasMore(data)) {
-      // If the `next` property in the API response is a URL, there are more options on the server
-      // side to be fetched.
-      this.more = data.next;
-    } else {
-      // If the `next` property in the API response is `null`, there are no more options on the
-      // server, and no additional fetching needs to occur.
-      this.more = null;
-    }
-  }
-
-  /**
-   * Fetch options from the given API URL and add them to the instance.
-   *
-   * @param url API URL
-   */
-  private async fetchOptions(url: Nullable<string>, action: ApplyMethod = 'merge'): Promise<void> {
-    if (typeof url === 'string') {
-      const data = await getApiData(url);
-
-      if (hasError(data)) {
-        if (isApiError(data)) {
-          return this.handleError(data.exception, data.error);
-        }
-        return this.handleError(`Error Fetching Options for field '${this.name}'`, data.error);
-      }
-      await this.processOptions(data, action);
-    }
-  }
-
-  /**
-   * Query the NetBox API for this element's options.
-   */
-  private async getOptions(action: ApplyMethod = 'merge'): Promise<void> {
-    if (this.queryUrl.includes(`{{`)) {
-      this.resetOptions();
-      return;
-    }
-    await this.fetchOptions(this.queryUrl, action);
-  }
-
-  /**
-   * Query the API for a specific search pattern and add the results to the available options.
-   */
-  private async handleSearch(event: Event) {
-    const { value: q } = event.target as HTMLInputElement;
-    const url = queryString.stringifyUrl({ url: this.queryUrl, query: { q } });
-    if (!url.includes(`{{`)) {
-      await this.fetchOptions(url, 'merge');
-      this.slim.data.search(q);
-      this.slim.render();
-    }
-    return;
-  }
-
-  /**
-   * Determine if the user has scrolled to the bottom of the options list. If so, try to load
-   * additional paginated options.
-   */
-  private handleScroll(): void {
-    // Floor scrollTop as chrome can return fractions on some zoom levels.
-    const atBottom =
-      Math.floor(this.slim.slim.list.scrollTop) + this.slim.slim.list.offsetHeight ===
-      this.slim.slim.list.scrollHeight;
-
-    if (this.atBottom && !atBottom) {
-      this.atBottom = false;
-      this.base.dispatchEvent(this.bottomEvent);
-    } else if (!this.atBottom && atBottom) {
-      this.atBottom = true;
-      this.base.dispatchEvent(this.bottomEvent);
-    }
-  }
-
-  /**
-   * Event handler to be dispatched any time a dependency's value changes. For example, when the
-   * value of `tenant_group` changes, `handleEvent` is called to get the current value of
-   * `tenant_group` and update the query parameters and API query URL for the `tenant` field.
-   */
-  private handleEvent(event: Event): void {
-    const target = event.target as HTMLSelectElement;
-    // Update the element's URL after any changes to a dependency.
-    this.updateQueryParams(target.name);
-    this.updatePathValues(target.name);
-    this.updateQueryUrl();
-
-    // Load new data.
-    Promise.all([this.loadData()]);
-  }
-
-  /**
-   * Event handler to be dispatched when the base select element is disabled or enabled. When that
-   * occurs, run the instance's `disable()` or `enable()` methods to synchronize UI state with
-   * desired action.
-   *
-   * @param event Dispatched event matching pattern `netbox.select.disabled.<name>`
-   */
-  private handleDisableEnable(event: Event): void {
-    const target = event.target as HTMLSelectElement;
-
-    if (target.disabled === true) {
-      this.disable();
-    } else if (target.disabled === false) {
-      this.enable();
-    }
-  }
-
-  /**
-   * When the API returns an error, show it to the user and reset this element's available options.
-   *
-   * @param title Error title
-   * @param message Error message
-   */
-  private handleError(title: string, message: string): void {
-    createToast('danger', title, message).show();
-    this.resetOptions();
-  }
-
-  /**
-   * `change` event callback to be called any time the value of a SlimSelect instance is changed.
-   */
-  private handleSlimChange(): void {
-    const element = this.slim.slim;
-    if (element) {
-      // Toggle form validation classes when form values change. For example, if the field was
-      // invalid and the value has now changed, remove the `.is-invalid` class.
-      if (
-        element.container.classList.contains('is-invalid') ||
-        this.base.classList.contains('is-invalid')
-      ) {
-        element.container.classList.remove('is-invalid');
-        this.base.classList.remove('is-invalid');
-      }
-    }
-    this.base.dispatchEvent(this.loadEvent);
-  }
-
-  /**
-   * Update the API query URL and underlying DOM element's `data-url` attribute.
-   */
-  private updateQueryUrl(): void {
-    // Create new URL query parameters based on the current state of `queryParams` and create an
-    // updated API query URL.
-    const query = {} as Dict<Stringifiable[]>;
-    for (const [key, value] of this.queryParams.entries()) {
-      query[key] = value;
-    }
-
-    let url = this.url;
-
-    // Replace any Django template variables in the URL with values from `pathValues` if set.
-    for (const [key, value] of this.pathValues.entries()) {
-      for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
-        if (isTruthy(value)) {
-          url = replaceAll(url, result[1], value.toString());
-        }
-      }
-    }
-    const newUrl = queryString.stringifyUrl({ url, query });
-    if (this.queryUrl !== newUrl) {
-      // Only update the URL if it has changed.
-      this.queryUrl = newUrl;
-      this.base.setAttribute('data-url', newUrl);
-    }
-  }
-
-  /**
-   * Update an element's API URL based on the value of another element on which this element
-   * relies.
-   *
-   * @param fieldName DOM ID of the other element.
-   */
-  private updateQueryParams(fieldName: string): void {
-    // Find the element dependency.
-    const element = document.querySelector<HTMLSelectElement>(`[name="${fieldName}"]`);
-    if (element !== null) {
-      // Initialize the element value as an array, in case there are multiple values.
-      let elementValue = [] as Stringifiable[];
-
-      if (element.multiple) {
-        // If this is a multi-select (form filters, tags, etc.), use all selected options as the value.
-        elementValue = Array.from(element.options)
-          .filter(o => o.selected)
-          .map(o => o.value);
-      } else if (element.value !== '') {
-        // If this is single-select (most fields), use the element's value. This seemingly
-        // redundant/verbose check is mainly for performance, so we're not running the above three
-        // functions (`Array.from()`, `Array.filter()`, `Array.map()`) every time every select
-        // field's value changes.
-        elementValue = [element.value];
-      }
-
-      if (elementValue.length > 0) {
-        // If the field has a value, add it to the map.
-        this.dynamicParams.updateValue(fieldName, elementValue);
-        // Get the updated value.
-        const current = this.dynamicParams.get(fieldName);
-
-        if (typeof current !== 'undefined') {
-          const { queryParam, queryValue } = current;
-          let value = [] as Stringifiable[];
-
-          if (this.staticParams.has(queryParam)) {
-            // If the field is defined in `staticParams`, we should merge the dynamic value with
-            // the static value.
-            const staticValue = this.staticParams.get(queryParam);
-            if (typeof staticValue !== 'undefined') {
-              value = [...staticValue, ...queryValue];
-            }
-          } else {
-            // If the field is _not_ defined in `staticParams`, we should replace the current value
-            // with the new dynamic value.
-            value = queryValue;
-          }
-          if (value.length > 0) {
-            this.queryParams.set(queryParam, value);
-          } else {
-            this.queryParams.delete(queryParam);
-          }
-        }
-      } else {
-        // Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
-        const queryParam = this.dynamicParams.queryParam(fieldName);
-        if (queryParam !== null) {
-          this.queryParams.delete(queryParam);
-        }
-      }
-    }
-  }
-
-  /**
-   * Update `pathValues` based on the form value of another element.
-   *
-   * @param id DOM ID of the other element.
-   */
-  private updatePathValues(id: string): void {
-    const key = replaceAll(id, /^id_/i, '');
-    const element = getElement<HTMLSelectElement>(`id_${key}`);
-    if (element !== null) {
-      // If this element's URL contains Django template tags ({{), replace the template tag
-      // with the the dependency's value. For example, if the dependency is the `rack` field,
-      // and the `rack` field's value is `1`, this element's URL would change from
-      // `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
-      const hasReplacement =
-        this.url.includes(`{{`) && Boolean(this.url.match(new RegExp(`({{(${id})}})`, 'g')));
-
-      if (hasReplacement) {
-        if (isTruthy(element.value)) {
-          // If the field has a value, add it to the map.
-          this.pathValues.set(id, element.value);
-        } else {
-          // Otherwise, reset the value.
-          this.pathValues.set(id, '');
-        }
-      }
-    }
-  }
-
-  /**
-   * Find the select element's placeholder text/label.
-   */
-  private getPlaceholder(): string {
-    let placeholder = this.name;
-    if (this.base.id) {
-      const label = document.querySelector(`label[for="${this.base.id}"]`) as HTMLLabelElement;
-      // Set the placeholder text to the label value, if it exists.
-      if (label !== null) {
-        placeholder = `Select ${label.innerText.trim()}`;
-      }
-    }
-    return placeholder;
-  }
-
-  /**
-   * Get this element's disabled options by value. The `data-query-param-exclude` attribute will
-   * contain a stringified JSON array of option values.
-   */
-  private getDisabledOptions(): string[] {
-    let disabledOptions = [] as string[];
-    if (hasExclusions(this.base)) {
-      try {
-        const exclusions = JSON.parse(
-          this.base.getAttribute('data-query-param-exclude') ?? '[]',
-        ) as string[];
-        disabledOptions = [...disabledOptions, ...exclusions];
-      } catch (err) {
-        console.group(
-          `Unable to parse data-query-param-exclude value on select element '${this.name}'`,
-        );
-        console.warn(err);
-        console.groupEnd();
-      }
-    }
-    return disabledOptions;
-  }
-
-  /**
-   * Get this element's disabled attribute keys. For example, if `disabled-indicator` is set to
-   * `'_occupied'` and an API object contains `{ _occupied: true }`, the option will be disabled.
-   */
-  private getDisabledAttributes(): string[] {
-    let disabled = [...DISABLED_ATTRIBUTES] as string[];
-    const attr = this.base.getAttribute('disabled-indicator');
-    if (isTruthy(attr)) {
-      disabled = [...disabled, attr];
-    }
-    return disabled;
-  }
-
-  /**
-   * Parse the `data-url` attribute to add any Django template variables to `pathValues` as keys
-   * with empty values. As those keys' corresponding form fields' values change, `pathValues` will
-   * be updated to reflect the new value.
-   */
-  private getPathKeys() {
-    for (const result of this.url.matchAll(new RegExp(`{{(.+)}}`, 'g'))) {
-      this.pathValues.set(result[1], '');
-    }
-  }
-
-  /**
-   * Determine if a this instances' options should be filtered by the value of another select
-   * element.
-   *
-   * Looks for the DOM attribute `data-dynamic-params`, the value of which is a JSON array of
-   * objects containing information about how to handle the related field.
-   */
-  private getDynamicParams(): void {
-    const serialized = this.base.getAttribute('data-dynamic-params');
-    try {
-      this.dynamicParams.addFromJson(serialized);
-    } catch (err) {
-      console.group(`Unable to determine dynamic query parameters for select field '${this.name}'`);
-      console.warn(err);
-      console.groupEnd();
-    }
-  }
-
-  /**
-   * Determine if this instance's options should be filtered by static values passed from the
-   * server.
-   *
-   * Looks for the DOM attribute `data-static-params`, the value of which is a JSON array of
-   * objects containing key/value pairs to add to `this.staticParams`.
-   */
-  private getStaticParams(): void {
-    const serialized = this.base.getAttribute('data-static-params');
-
-    try {
-      if (isTruthy(serialized)) {
-        const deserialized = JSON.parse(serialized);
-        if (isStaticParams(deserialized)) {
-          for (const { queryParam, queryValue } of deserialized) {
-            if (Array.isArray(queryValue)) {
-              this.staticParams.set(queryParam, queryValue);
-            } else {
-              this.staticParams.set(queryParam, [queryValue]);
-            }
-          }
-        }
-      }
-    } catch (err) {
-      console.group(`Unable to determine static query parameters for select field '${this.name}'`);
-      console.warn(err);
-      console.groupEnd();
-    }
-  }
-
-  /**
-   * 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 feel cleaner in the DOM.
-   */
-  private setSlimStyles(): void {
-    const { width, height } = this.slim.slim.container.getBoundingClientRect();
-    this.base.style.opacity = '0';
-    this.base.style.width = `${width}px`;
-    this.base.style.height = `${height}px`;
-    this.base.style.display = 'block';
-    this.base.style.position = 'absolute';
-    this.base.style.pointerEvents = 'none';
-  }
-
-  /**
-   * 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.
-   */
-  private setOptionStyles(): void {
-    for (const option of this.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.setAttribute('data-netbox', id);
-
-        // Scope the CSS to apply both the list item and the selected item.
-        style.innerHTML = replaceAll(
-          `
-  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;
-  }
-              `,
-          '\n',
-          '',
-        ).trim();
-
-        // Add the style element to the DOM.
-        document.head.appendChild(style);
-      }
-    }
-  }
-
-  /**
-   * Remove base element classes from SlimSelect instance.
-   */
-  private resetClasses(): void {
-    const element = this.slim.slim;
-    if (element) {
-      for (const className of this.base.classList) {
-        element.container.classList.remove(className);
-      }
-    }
-  }
-
-  /**
-   * Initialize any adjacent reset buttons so that when clicked, the page is reloaded without
-   * query parameters.
-   */
-  private initResetButton(): void {
-    const resetButton = findFirstAdjacent<HTMLButtonElement>(
-      this.base,
-      'button[data-reset-select]',
-    );
-    if (resetButton !== null) {
-      resetButton.addEventListener('click', () => {
-        window.location.assign(window.location.origin + window.location.pathname);
-      });
-    }
-  }
-
-  /**
-   * Add a refresh button to the search container element. When clicked, the API data will be
-   * reloaded.
-   */
-  private initRefreshButton(): void {
-    if (this.allowRefresh) {
-      const refreshButton = createElement(
-        'button',
-        { type: 'button' },
-        ['btn', 'btn-sm', 'btn-ghost-dark'],
-        [createElement('i', null, ['mdi', 'mdi-reload'])],
-      );
-      refreshButton.addEventListener('click', () => this.loadData());
-      refreshButton.type = 'button';
-      this.slim.slim.search.container.appendChild(refreshButton);
-    }
-  }
-}

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

@@ -1,10 +0,0 @@
-import { getElements } from '../../util';
-import { APISelect } from './apiSelect';
-
-export function initApiSelect(): void {
-  for (const select of getElements<HTMLSelectElement>('.netbox-api-select:not([data-ssid])')) {
-    new APISelect(select);
-  }
-}
-
-export type { Trigger } from './types';

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

@@ -1,199 +0,0 @@
-import type { Stringifiable } from 'query-string';
-import type { Option, Optgroup } from 'slim-select/dist/data';
-
-/**
- * Map of string keys to primitive array values accepted by `query-string`. Keys are used as
- * URL query parameter keys. Values correspond to query param values, enforced as an array
- * for easier handling. For example, a mapping of `{ site_id: [1, 2] }` is serialized by
- * `query-string` as `?site_id=1&site_id=2`. Likewise, `{ site_id: [1] }` is serialized as
- * `?site_id=1`.
- */
-export type QueryFilter = Map<string, Stringifiable[]>;
-
-/**
- * Tracked data for a related field. This is the value of `APISelect.filterFields`.
- */
-export type FilterFieldValue = {
-  /**
-   * Key to use in the query parameter itself.
-   */
-  queryParam: string;
-  /**
-   * Value to use in the query parameter for the related field.
-   */
-  queryValue: Stringifiable[];
-  /**
-   * @see `DataFilterFields.includeNull`
-   */
-  includeNull: boolean;
-};
-
-/**
- * JSON data structure from `data-dynamic-params` attribute.
- */
-export type DataDynamicParam = {
-  /**
-   * Name of form field to track.
-   *
-   * @example [name="tenant_group"]
-   */
-  fieldName: string;
-  /**
-   * Query param key.
-   *
-   * @example group_id
-   */
-  queryParam: string;
-};
-
-/**
- * `queryParams` Map value.
- */
-export type QueryParam = {
-  queryParam: string;
-  queryValue: Stringifiable[];
-};
-
-/**
- * JSON data structure from `data-static-params` attribute.
- */
-export type DataStaticParam = {
-  queryParam: string;
-  queryValue: Stringifiable | Stringifiable[];
-};
-
-/**
- * JSON data passed from Django on the `data-filter-fields` attribute.
- */
-export type DataFilterFields = {
-  /**
-   * Related field form name (`[name="<fieldName>"]`)
-   *
-   * @example tenant_group
-   */
-  fieldName: string;
-  /**
-   * Key to use in the query parameter itself.
-   *
-   * @example group_id
-   */
-  queryParam: string;
-  /**
-   * Optional default value. If set, value will be added to the query parameters prior to the
-   * initial API call and will be maintained until the field `fieldName` references (if one exists)
-   * is updated with a new value.
-   *
-   * @example 1
-   */
-  defaultValue: Nullable<Stringifiable | Stringifiable[]>;
-  /**
-   * Include `null` on queries for the related field. For example, if `true`, `?<fieldName>=null`
-   * will be added to all API queries for this field.
-   */
-  includeNull: boolean;
-};
-
-/**
- * Map of string keys to primitive values. Used to track variables within URLs from the server. For
- * example, `/api/$key/thing`. `PathFilter` tracks `$key` as `{ key: '' }` in the map, and when the
- * value is later known, the value is set — `{ key: 'value' }`, and the URL is transformed to
- * `/api/value/thing`.
- */
-export type PathFilter = Map<string, Stringifiable>;
-
-/**
- * Merge or replace incoming options with current options.
- */
-export type ApplyMethod = 'merge' | 'replace';
-
-/**
- * Trigger for which the select instance should fetch its data from the NetBox API.
- */
-export type Trigger =
-  /**
-   * Load data when the select element is opened.
-   */
-  | 'open'
-  /**
-   * Load data when the element is loaded.
-   */
-  | 'load'
-  /**
-   * Load data when a parent element is uncollapsed.
-   */
-  | 'collapse';
-
-/**
- * Strict Type Guard to determine if a deserialized value from the `data-filter-fields` attribute
- * is of type `DataFilterFields`.
- *
- * @param value Deserialized value from `data-filter-fields` attribute.
- */
-export function isDataFilterFields(value: unknown): value is DataFilterFields[] {
-  if (Array.isArray(value)) {
-    for (const item of value) {
-      if (typeof item === 'object' && item !== null) {
-        if ('fieldName' in item && 'queryParam' in item) {
-          return (
-            typeof (item as DataFilterFields).fieldName === 'string' &&
-            typeof (item as DataFilterFields).queryParam === 'string'
-          );
-        }
-      }
-    }
-  }
-  return false;
-}
-
-/**
- * Strict Type Guard to determine if a deserialized value from the `data-dynamic-params` attribute
- * is of type `DataDynamicParam[]`.
- *
- * @param value Deserialized value from `data-dynamic-params` attribute.
- */
-export function isDataDynamicParams(value: unknown): value is DataDynamicParam[] {
-  if (Array.isArray(value)) {
-    for (const item of value) {
-      if (typeof item === 'object' && item !== null) {
-        if ('fieldName' in item && 'queryParam' in item) {
-          return (
-            typeof (item as DataDynamicParam).fieldName === 'string' &&
-            typeof (item as DataDynamicParam).queryParam === 'string'
-          );
-        }
-      }
-    }
-  }
-  return false;
-}
-
-/**
- * Strict Type Guard to determine if a deserialized value from the `data-static-params` attribute
- * is of type `DataStaticParam[]`.
- *
- * @param value Deserialized value from `data-static-params` attribute.
- */
-export function isStaticParams(value: unknown): value is DataStaticParam[] {
-  if (Array.isArray(value)) {
-    for (const item of value) {
-      if (typeof item === 'object' && item !== null) {
-        if ('queryParam' in item && 'queryValue' in item) {
-          return (
-            typeof (item as DataStaticParam).queryParam === 'string' &&
-            typeof (item as DataStaticParam).queryValue !== 'undefined'
-          );
-        }
-      }
-    }
-  }
-  return false;
-}
-
-/**
- * Type guard to determine if a SlimSelect `dataObject` is an `Option`.
- *
- * @param data Option or Option Group
- */
-export function isOption(data: Option | Optgroup): data is Option {
-  return !('options' in data);
-}

+ 2 - 2
netbox/project-static/src/select/api/dynamicParams.ts → netbox/project-static/src/select/classes/dynamicParamsMap.ts

@@ -1,7 +1,7 @@
 import { isTruthy } from '../../util';
-import { isDataDynamicParams } from './types';
+import { isDataDynamicParams } from '../types';
 
-import type { QueryParam } from './types';
+import type { QueryParam } from '../types';
 
 /**
  * Extension of built-in `Map` to add convenience functions.

+ 305 - 0
netbox/project-static/src/select/classes/dynamicTomSelect.ts

@@ -0,0 +1,305 @@
+import { RecursivePartial, TomInput, TomOption, TomSettings } from 'tom-select/dist/types/types';
+import { addClasses } from 'tom-select/src/vanilla'
+import queryString from 'query-string';
+import TomSelect from 'tom-select';
+import type { Stringifiable } from 'query-string';
+import { DynamicParamsMap } from './dynamicParamsMap';
+
+// Transitional
+import { QueryFilter, PathFilter } from '../types'
+import { getElement, replaceAll } from '../../util';
+
+
+// Extends TomSelect to provide enhanced fetching of options via the REST API
+export class DynamicTomSelect extends TomSelect {
+
+  public readonly nullOption: Nullable<TomOption> = null;
+
+  // Transitional code from APISelect
+  private readonly queryParams: QueryFilter = new Map();
+  private readonly staticParams: QueryFilter = new Map();
+  private readonly dynamicParams: DynamicParamsMap = new DynamicParamsMap();
+  private readonly pathValues: PathFilter = new Map();
+
+  /**
+   * Overrides
+   */
+
+  constructor( input_arg: string|TomInput, user_settings: RecursivePartial<TomSettings> ) {
+    super(input_arg, user_settings);
+
+    // Glean the REST API endpoint URL from the <select> element
+    this.api_url = this.input.getAttribute('data-url') as string;
+
+    // Set the null option (if any)
+    const nullOption = this.input.getAttribute('data-null-option');
+    if (nullOption) {
+      let valueField = this.settings.valueField;
+      let labelField = this.settings.labelField;
+      this.nullOption = {}
+      this.nullOption[valueField] = 'null';
+      this.nullOption[labelField] = nullOption;
+    }
+
+    // Populate static query parameters.
+    this.getStaticParams();
+    for (const [key, value] of this.staticParams.entries()) {
+      this.queryParams.set(key, value);
+    }
+
+    // Populate dynamic query parameters
+    this.getDynamicParams();
+    for (const filter of this.dynamicParams.keys()) {
+      this.updateQueryParams(filter);
+    }
+
+    // Path values
+    this.getPathKeys();
+    for (const filter of this.pathValues.keys()) {
+      this.updatePathValues(filter);
+    }
+
+    // Add dependency event listeners.
+    this.addEventListeners();
+  }
+
+  load(value: string) {
+    const self = this;
+    const url = self.getRequestUrl(value);
+
+    // Automatically clear any cached options. (Only options included
+    // in the API response should be present.)
+    self.clearOptions();
+
+    addClasses(self.wrapper, self.settings.loadingClass);
+    self.loading++;
+
+    // Populate the null option (if any) if not searching
+    if (self.nullOption && !value) {
+      self.addOption(self.nullOption);
+    }
+
+    // Make the API request
+    fetch(url)
+      .then(response => response.json())
+      .then(json => {
+          self.loadCallback(json.results, []);
+      }).catch(()=>{
+          self.loadCallback([], []);
+      });
+
+  }
+
+  /**
+   * Custom methods
+   */
+
+  // Formulate and return the complete URL for an API request, including any query parameters.
+  getRequestUrl(search: string): string {
+    let url = this.api_url;
+
+    // Create new URL query parameters based on the current state of `queryParams` and create an
+    // updated API query URL.
+    const query = {} as Dict<Stringifiable[]>;
+    for (const [key, value] of this.queryParams.entries()) {
+      query[key] = value;
+    }
+
+    // Replace any variables in the URL with values from `pathValues` if set.
+    for (const [key, value] of this.pathValues.entries()) {
+      for (const result of this.api_url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
+        if (value) {
+          url = replaceAll(url, result[1], value.toString());
+        }
+      }
+    }
+
+    // Append the search query, if any
+    if (search) {
+      query['q'] = [search];
+    }
+
+    // Add standard parameters
+    query['brief'] = [true];
+    query['limit'] = [this.settings.maxOptions];
+
+    return queryString.stringifyUrl({ url, query });
+  }
+
+  /**
+   * Transitional methods
+   */
+
+  // Determine if this instance's options should be filtered by static values passed from the
+  // server. Looks for the DOM attribute `data-static-params`, the value of which is a JSON
+  // array of objects containing key/value pairs to add to `this.staticParams`.
+  private getStaticParams(): void {
+    const serialized = this.input.getAttribute('data-static-params');
+
+    try {
+      if (serialized) {
+        const deserialized = JSON.parse(serialized);
+        if (deserialized) {
+          for (const { queryParam, queryValue } of deserialized) {
+            if (Array.isArray(queryValue)) {
+              this.staticParams.set(queryParam, queryValue);
+            } else {
+              this.staticParams.set(queryParam, [queryValue]);
+            }
+          }
+        }
+      }
+    } catch (err) {
+      console.group(`Unable to determine static query parameters for select field '${this.name}'`);
+      console.warn(err);
+      console.groupEnd();
+    }
+  }
+
+  // Determine if this instances' options should be filtered by the value of another select
+  // element. Looks for the DOM attribute `data-dynamic-params`, the value of which is a JSON
+  // array of objects containing information about how to handle the related field.
+  private getDynamicParams(): void {
+    const serialized = this.input.getAttribute('data-dynamic-params');
+    try {
+      this.dynamicParams.addFromJson(serialized);
+    } catch (err) {
+      console.group(`Unable to determine dynamic query parameters for select field '${this.name}'`);
+      console.warn(err);
+      console.groupEnd();
+    }
+  }
+
+
+  // Parse the `data-url` attribute to add any variables to `pathValues` as keys with empty
+  // values. As those keys' corresponding form fields' values change, `pathValues` will be
+  // updated to reflect the new value.
+  private getPathKeys() {
+    for (const result of this.api_url.matchAll(new RegExp(`{{(.+)}}`, 'g'))) {
+      this.pathValues.set(result[1], '');
+    }
+  }
+
+  // Update an element's API URL based on the value of another element on which this element
+  // relies.
+  private updateQueryParams(fieldName: string): void {
+    // Find the element dependency.
+    const element = document.querySelector<HTMLSelectElement>(`[name="${fieldName}"]`);
+    if (element !== null) {
+      // Initialize the element value as an array, in case there are multiple values.
+      let elementValue = [] as Stringifiable[];
+
+      if (element.multiple) {
+        // If this is a multi-select (form filters, tags, etc.), use all selected options as the value.
+        elementValue = Array.from(element.options)
+          .filter(o => o.selected)
+          .map(o => o.value);
+      } else if (element.value !== '') {
+        // If this is single-select (most fields), use the element's value. This seemingly
+        // redundant/verbose check is mainly for performance, so we're not running the above three
+        // functions (`Array.from()`, `Array.filter()`, `Array.map()`) every time every select
+        // field's value changes.
+        elementValue = [element.value];
+      }
+
+      if (elementValue.length > 0) {
+        // If the field has a value, add it to the map.
+        this.dynamicParams.updateValue(fieldName, elementValue);
+        // Get the updated value.
+        const current = this.dynamicParams.get(fieldName);
+
+        if (typeof current !== 'undefined') {
+          const { queryParam, queryValue } = current;
+          let value = [] as Stringifiable[];
+
+          if (this.staticParams.has(queryParam)) {
+            // If the field is defined in `staticParams`, we should merge the dynamic value with
+            // the static value.
+            const staticValue = this.staticParams.get(queryParam);
+            if (typeof staticValue !== 'undefined') {
+              value = [...staticValue, ...queryValue];
+            }
+          } else {
+            // If the field is _not_ defined in `staticParams`, we should replace the current value
+            // with the new dynamic value.
+            value = queryValue;
+          }
+          if (value.length > 0) {
+            this.queryParams.set(queryParam, value);
+          } else {
+            this.queryParams.delete(queryParam);
+          }
+        }
+      } else {
+        // Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
+        const queryParam = this.dynamicParams.queryParam(fieldName);
+        if (queryParam !== null) {
+          this.queryParams.delete(queryParam);
+        }
+      }
+    }
+  }
+
+  // Update `pathValues` based on the form value of another element.
+  private updatePathValues(id: string): void {
+    const key = replaceAll(id, /^id_/i, '');
+    const element = getElement<HTMLSelectElement>(`id_${key}`);
+    if (element !== null) {
+      // If this element's URL contains variable tags ({{), replace the tag with the dependency's
+      // value. For example, if the dependency is the `rack` field, and the `rack` field's value
+      // is `1`, this element's URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
+      const hasReplacement =
+        this.api_url.includes(`{{`) && Boolean(this.api_url.match(new RegExp(`({{(${id})}})`, 'g')));
+
+      if (hasReplacement) {
+        if (element.value) {
+          // If the field has a value, add it to the map.
+          this.pathValues.set(id, element.value);
+        } else {
+          // Otherwise, reset the value.
+          this.pathValues.set(id, '');
+        }
+      }
+    }
+  }
+
+  /**
+   * Events
+   */
+
+  // Add event listeners to this element and its dependencies so that when dependencies change
+  //this element's options are updated.
+  private addEventListeners(): void {
+    // Create a unique iterator of all possible form fields which, when changed, should cause this
+    // element to update its API query.
+    const dependencies = new Set([...this.dynamicParams.keys(), ...this.pathValues.keys()]);
+
+    for (const dep of dependencies) {
+      const filterElement = document.querySelector(`[name="${dep}"]`);
+      if (filterElement !== null) {
+        // Subscribe to dependency changes.
+        filterElement.addEventListener('change', event => this.handleEvent(event));
+      }
+      // Subscribe to changes dispatched by this state manager.
+      this.input.addEventListener(`netbox.select.onload.${dep}`, event => this.handleEvent(event));
+    }
+  }
+
+  // Event handler to be dispatched any time a dependency's value changes. For example, when the
+  // value of `tenant_group` changes, `handleEvent` is called to get the current value of
+  // `tenant_group` and update the query parameters and API query URL for the `tenant` field.
+  private handleEvent(event: Event): void {
+    const target = event.target as HTMLSelectElement;
+
+    // Update the element's URL after any changes to a dependency.
+    this.updateQueryParams(target.name);
+    this.updatePathValues(target.name);
+
+    // Clear any previous selection(s) as the parent filter has changed
+    this.clear();
+
+    // Load new data.
+    this.load(this.lastValue);
+  }
+
+}

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

@@ -1,82 +0,0 @@
-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 !== '';
-}
-
-/**
- * Style the container element based on the selected option value.
- */
-function styleContainer(
-  instance: InstanceType<typeof SlimSelect>,
-  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;
-    } else {
-      // If the color cannot be set (i.e., the placeholder), remove any inline styles.
-      instance.slim.singleSelected.container.removeAttribute('style');
-    }
-  }
-}
-
-/**
- * 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-color-select:not([data-ssid])',
-  )) {
-    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="mdi mdi-close-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(instance, option);
-        break;
-      }
-    }
-
-    // Don't inherit the select element's classes.
-    for (const className of select.classList) {
-      instance.slim.container.classList.remove(className);
-    }
-
-    // Change the SlimSelect container's style based on the selected option.
-    instance.onChange = option => styleContainer(instance, option);
-  }
-}

+ 9 - 0
netbox/project-static/src/select/config.ts

@@ -0,0 +1,9 @@
+export const config = {
+  plugins: {
+    // Provides the "clear" button on the widget
+    clear_button: {
+      html: (data: Dict) =>
+        `<i class="mdi mdi-close-circle ${data.className}" title="${data.title}"></i>`,
+    },
+  },
+};

+ 51 - 0
netbox/project-static/src/select/dynamic.ts

@@ -0,0 +1,51 @@
+import { TomOption } from 'tom-select/src/types';
+import { escape_html } from 'tom-select/src/utils';
+import { DynamicTomSelect } from './classes/dynamicTomSelect';
+import { config } from './config';
+import { getElements } from '../util';
+
+const VALUE_FIELD = 'id';
+const LABEL_FIELD = 'display';
+const MAX_OPTIONS = 100;
+
+// Render the HTML for a dropdown option
+function renderOption(data: TomOption, escape: typeof escape_html) {
+  // If the option has a `_depth` property, indent its label
+  if (typeof data._depth === 'number' && data._depth > 0) {
+    return `<div>${'─'.repeat(data._depth)} ${escape(data[LABEL_FIELD])}</div>`;
+  }
+
+  return `<div>${escape(data[LABEL_FIELD])}</div>`;
+}
+
+// Initialize <select> elements which are populated via a REST API call
+export function initDynamicSelects(): void {
+  for (const select of getElements<HTMLSelectElement>('select.api-select')) {
+    new DynamicTomSelect(select, {
+      ...config,
+      valueField: VALUE_FIELD,
+      labelField: LABEL_FIELD,
+      maxOptions: MAX_OPTIONS,
+
+      // Disable local search (search is performed on the backend)
+      searchField: [],
+
+      // Reference the disabled-indicator attr on the <select> element to determine
+      // the name of the attribute which indicates whether an option should be disabled
+      disabledField: select.getAttribute('disabled-indicator') || undefined,
+
+      // Load options from API immediately on focus
+      preload: 'focus',
+
+      // Define custom rendering functions
+      render: {
+        option: renderOption,
+      },
+
+      // By default, load() will be called only if query.length > 0
+      shouldLoad: function (): boolean {
+        return true;
+      },
+    });
+  }
+}

+ 6 - 7
netbox/project-static/src/select/index.ts

@@ -1,9 +1,8 @@
-import { initApiSelect } from './api';
-import { initColorSelect } from './color';
-import { initStaticSelect } from './static';
+import { initColorSelects, initStaticSelects } from './static';
+import { initDynamicSelects } from './dynamic';
 
-export function initSelect(): void {
-  for (const func of [initApiSelect, initColorSelect, initStaticSelect]) {
-    func();
-  }
+export function initSelects(): void {
+  initStaticSelects();
+  initDynamicSelects();
+  initColorSelects();
 }

+ 25 - 22
netbox/project-static/src/select/static.ts

@@ -1,27 +1,30 @@
-import SlimSelect from 'slim-select';
+import { TomOption } from 'tom-select/src/types';
+import TomSelect from 'tom-select';
+import { escape_html } from 'tom-select/src/utils';
+import { config } from './config';
 import { getElements } from '../util';
 
-export function initStaticSelect(): void {
-  for (const select of getElements<HTMLSelectElement>('.netbox-static-select:not([data-ssid])')) {
-    if (select !== null) {
-      const label = document.querySelector(`label[for="${select.id}"]`) as HTMLLabelElement;
-
-      let placeholder;
-      if (label !== null) {
-        placeholder = `Select ${label.innerText.trim()}`;
-      }
-
-      const instance = new SlimSelect({
-        select,
-        allowDeselect: true,
-        deselectLabel: `<i class="mdi mdi-close-circle"></i>`,
-        placeholder,
-      });
+// Initialize <select> elements with statically-defined options
+export function initStaticSelects(): void {
+  for (const select of getElements<HTMLSelectElement>(
+    'select:not(.api-select):not(.color-select)',
+  )) {
+    new TomSelect(select, {
+      ...config,
+    });
+  }
+}
 
-      // Don't copy classes from select element to SlimSelect instance.
-      for (const className of select.classList) {
-        instance.slim.container.classList.remove(className);
-      }
-    }
+// Initialize color selection fields
+export function initColorSelects(): void {
+  for (const select of getElements<HTMLSelectElement>('select.color-select')) {
+    new TomSelect(select, {
+      ...config,
+      render: {
+        option: function (item: TomOption, escape: typeof escape_html) {
+          return `<div style="background-color: #${escape(item.value)}">${escape(item.text)}</div>`;
+        },
+      },
+    });
   }
 }

+ 66 - 0
netbox/project-static/src/select/types.ts

@@ -0,0 +1,66 @@
+import type { Stringifiable } from 'query-string';
+
+/**
+ * Map of string keys to primitive array values accepted by `query-string`. Keys are used as
+ * URL query parameter keys. Values correspond to query param values, enforced as an array
+ * for easier handling. For example, a mapping of `{ site_id: [1, 2] }` is serialized by
+ * `query-string` as `?site_id=1&site_id=2`. Likewise, `{ site_id: [1] }` is serialized as
+ * `?site_id=1`.
+ */
+export type QueryFilter = Map<string, Stringifiable[]>;
+
+/**
+ * JSON data structure from `data-dynamic-params` attribute.
+ */
+export type DataDynamicParam = {
+  /**
+   * Name of form field to track.
+   *
+   * @example [name="tenant_group"]
+   */
+  fieldName: string;
+  /**
+   * Query param key.
+   *
+   * @example group_id
+   */
+  queryParam: string;
+};
+
+/**
+ * `queryParams` Map value.
+ */
+export type QueryParam = {
+  queryParam: string;
+  queryValue: Stringifiable[];
+};
+
+/**
+ * Map of string keys to primitive values. Used to track variables within URLs from the server. For
+ * example, `/api/$key/thing`. `PathFilter` tracks `$key` as `{ key: '' }` in the map, and when the
+ * value is later known, the value is set — `{ key: 'value' }`, and the URL is transformed to
+ * `/api/value/thing`.
+ */
+export type PathFilter = Map<string, Stringifiable>;
+
+/**
+ * Strict Type Guard to determine if a deserialized value from the `data-dynamic-params` attribute
+ * is of type `DataDynamicParam[]`.
+ *
+ * @param value Deserialized value from `data-dynamic-params` attribute.
+ */
+export function isDataDynamicParams(value: unknown): value is DataDynamicParam[] {
+  if (Array.isArray(value)) {
+    for (const item of value) {
+      if (typeof item === 'object' && item !== null) {
+        if ('fieldName' in item && 'queryParam' in item) {
+          return (
+            typeof (item as DataDynamicParam).fieldName === 'string' &&
+            typeof (item as DataDynamicParam).queryParam === 'string'
+          );
+        }
+      }
+    }
+  }
+  return false;
+}

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

@@ -1,26 +0,0 @@
-import type { Trigger } from './api';
-
-/**
- * Determine if an element has the `data-url` attribute set.
- */
-export function hasUrl(el: HTMLSelectElement): el is HTMLSelectElement & { 'data-url': string } {
-  const value = el.getAttribute('data-url');
-  return typeof value === 'string' && value !== '';
-}
-
-/**
- * Determine if an element has the `data-query-param-exclude` attribute set.
- */
-export function hasExclusions(
-  el: HTMLSelectElement,
-): el is HTMLSelectElement & { 'data-query-param-exclude': string } {
-  const exclude = el.getAttribute('data-query-param-exclude');
-  return typeof exclude === 'string' && exclude !== '';
-}
-
-/**
- * Determine if a trigger value is valid.
- */
-export function isTrigger(value: unknown): value is Trigger {
-  return typeof value === 'string' && ['load', 'open', 'collapse'].includes(value);
-}

+ 2 - 1
netbox/project-static/styles/netbox.scss

@@ -1,7 +1,8 @@
 @import 'variables';
 
-// Tabler
+// Tabler & vendors
 @import '../node_modules/@tabler/core/src/scss/_core.scss';
+@import '../node_modules/@tabler/core/src/scss/vendor/tom-select';
 
 // Overrides of external libraries
 @import 'overrides/slim-select';

+ 2 - 1
netbox/project-static/tsconfig.json

@@ -3,7 +3,8 @@
     "forceConsistentCasingInFileNames": true,
     "allowSyntheticDefaultImports": true,
     "moduleResolution": "node",
-    "noUnusedParameters": true,
+    // tom-select v2.3.1 raises several TS6133 errors with noUnusedParameters
+    "noUnusedParameters": false,
     "esModuleInterop": true,
     "isolatedModules": true,
     "noUnusedLocals": true,

+ 237 - 65
netbox/project-static/yarn.lock

@@ -2,6 +2,11 @@
 # yarn lockfile v1
 
 
+"@esbuild/linux-loong64@0.14.54":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
+  integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==
+
 "@eslint/eslintrc@^1.3.2":
   version "1.3.2"
   resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.2.tgz"
@@ -67,7 +72,7 @@
     "@nodelib/fs.stat" "2.0.5"
     run-parallel "^1.1.9"
 
-"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
+"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
   version "2.0.5"
   resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
   integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -80,6 +85,18 @@
     "@nodelib/fs.scandir" "2.1.5"
     fastq "^1.6.0"
 
+"@orchidjs/sifter@^1.0.3":
+  version "1.0.3"
+  resolved "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.0.3.tgz"
+  integrity sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g==
+  dependencies:
+    "@orchidjs/unicode-variants" "^1.0.4"
+
+"@orchidjs/unicode-variants@^1.0.4":
+  version "1.0.4"
+  resolved "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz"
+  integrity sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ==
+
 "@pkgr/utils@^2.3.1":
   version "2.3.1"
   resolved "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz"
@@ -92,16 +109,11 @@
     tiny-glob "^0.2.9"
     tslib "^2.4.0"
 
-"@popperjs/core@^2.11.8":
+"@popperjs/core@^2.11.6", "@popperjs/core@^2.11.8", "@popperjs/core@^2.9.2":
   version "2.11.8"
-  resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz"
+  resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
   integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
 
-"@popperjs/core@^2.9.2":
-  version "2.11.6"
-  resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz"
-  integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==
-
 "@tabler/core@1.0.0-beta20":
   version "1.0.0-beta20"
   resolved "https://registry.npmjs.org/@tabler/core/-/core-1.0.0-beta20.tgz"
@@ -138,6 +150,13 @@
   resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
   integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
 
+"@types/node@^20.11.16":
+  version "20.11.16"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.16.tgz#4411f79411514eb8e2926f036c86c9f0e4ec6708"
+  integrity sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==
+  dependencies:
+    undici-types "~5.26.4"
+
 "@typescript-eslint/eslint-plugin@^5.39.0":
   version "5.39.0"
   resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.39.0.tgz"
@@ -152,7 +171,7 @@
     semver "^7.3.7"
     tsutils "^3.21.0"
 
-"@typescript-eslint/parser@^5.0.0", "@typescript-eslint/parser@^5.39.0":
+"@typescript-eslint/parser@^5.39.0":
   version "5.39.0"
   resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.39.0.tgz"
   integrity sha512-PhxLjrZnHShe431sBAGHaNe6BDdxAASDySgsBCGxcBecVCi8NQWxQZMcizNA4g0pN51bBAn/FUfkWG3SDVcGlA==
@@ -223,7 +242,7 @@ acorn-jsx@^5.3.2:
   resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
   integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
 
-"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.8.0:
+acorn@^8.8.0:
   version "8.8.0"
   resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz"
   integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==
@@ -577,6 +596,71 @@ es-to-primitive@^1.2.1:
     is-date-object "^1.0.1"
     is-symbol "^1.0.2"
 
+esbuild-android-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be"
+  integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==
+
+esbuild-android-arm64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz#3fc3ff0bab76fe35dd237476b5d2b32bb20a3d44"
+  integrity sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg==
+
+esbuild-android-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771"
+  integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==
+
+esbuild-darwin-64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz#8e9169c16baf444eacec60d09b24d11b255a8e72"
+  integrity sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ==
+
+esbuild-darwin-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25"
+  integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==
+
+esbuild-darwin-arm64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.15.tgz#1b07f893b632114f805e188ddfca41b2b778229a"
+  integrity sha512-i1FZssTVxUqNlJ6cBTj5YQj4imWy3m49RZRnHhLpefFIh0To05ow9DTrXROTE1urGTQCloFUXTX8QfGJy1P8dQ==
+
+esbuild-darwin-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73"
+  integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==
+
+esbuild-freebsd-64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz#0b8b7eca1690c8ec94c75680c38c07269c1f4a85"
+  integrity sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA==
+
+esbuild-freebsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d"
+  integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==
+
+esbuild-freebsd-arm64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz#2e1a6c696bfdcd20a99578b76350b41db1934e52"
+  integrity sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ==
+
+esbuild-freebsd-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48"
+  integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==
+
+esbuild-linux-32@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz#6fd39f36fc66dd45b6b5f515728c7bbebc342a69"
+  integrity sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g==
+
+esbuild-linux-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5"
+  integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==
+
 esbuild-linux-64@0.13.15:
   version "0.13.15"
   resolved "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz"
@@ -587,6 +671,76 @@ esbuild-linux-64@0.14.54:
   resolved "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz"
   integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
 
+esbuild-linux-arm64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz#3891aa3704ec579a1b92d2a586122e5b6a2bfba1"
+  integrity sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA==
+
+esbuild-linux-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b"
+  integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==
+
+esbuild-linux-arm@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz#8a00e99e6a0c6c9a6b7f334841364d8a2b4aecfe"
+  integrity sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA==
+
+esbuild-linux-arm@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59"
+  integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==
+
+esbuild-linux-mips64le@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz#36b07cc47c3d21e48db3bb1f4d9ef8f46aead4f7"
+  integrity sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg==
+
+esbuild-linux-mips64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34"
+  integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==
+
+esbuild-linux-ppc64le@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz#f7e6bba40b9a11eb9dcae5b01550ea04670edad2"
+  integrity sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ==
+
+esbuild-linux-ppc64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e"
+  integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==
+
+esbuild-linux-riscv64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8"
+  integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==
+
+esbuild-linux-s390x@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6"
+  integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==
+
+esbuild-netbsd-64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.15.tgz#a2fedc549c2b629d580a732d840712b08d440038"
+  integrity sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w==
+
+esbuild-netbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81"
+  integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==
+
+esbuild-openbsd-64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz#b22c0e5806d3a1fbf0325872037f885306b05cd7"
+  integrity sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g==
+
+esbuild-openbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b"
+  integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==
+
 esbuild-sass-plugin@^2.3.3:
   version "2.3.3"
   resolved "https://registry.npmjs.org/esbuild-sass-plugin/-/esbuild-sass-plugin-2.3.3.tgz"
@@ -596,6 +750,46 @@ esbuild-sass-plugin@^2.3.3:
     resolve "^1.22.1"
     sass "^1.49.0"
 
+esbuild-sunos-64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz#d0b6454a88375ee8d3964daeff55c85c91c7cef4"
+  integrity sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw==
+
+esbuild-sunos-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da"
+  integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==
+
+esbuild-windows-32@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz#c96d0b9bbb52f3303322582ef8e4847c5ad375a7"
+  integrity sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw==
+
+esbuild-windows-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31"
+  integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==
+
+esbuild-windows-64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz#1f79cb9b1e1bb02fb25cd414cb90d4ea2892c294"
+  integrity sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ==
+
+esbuild-windows-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4"
+  integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==
+
+esbuild-windows-arm64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz#482173070810df22a752c686509c370c3be3b3c3"
+  integrity sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA==
+
+esbuild-windows-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982"
+  integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==
+
 esbuild@^0.13.15:
   version "0.13.15"
   resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.13.15.tgz"
@@ -689,7 +883,7 @@ eslint-module-utils@^2.7.3:
   dependencies:
     debug "^3.2.7"
 
-eslint-plugin-import@*, eslint-plugin-import@^2.26.0:
+eslint-plugin-import@^2.26.0:
   version "2.26.0"
   resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz"
   integrity sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==
@@ -748,7 +942,7 @@ eslint-visitor-keys@^3.3.0:
   resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz"
   integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==
 
-eslint@*, "eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8", "eslint@^6.0.0 || ^7.0.0 || ^8.0.0", eslint@^8.24.0, eslint@>=5, eslint@>=7.0.0, eslint@>=7.28.0:
+eslint@^8.24.0:
   version "8.24.0"
   resolved "https://registry.npmjs.org/eslint/-/eslint-8.24.0.tgz"
   integrity sha512-dWFaPhGhTAiPcCgm3f6LI2MBWbogMnTJzFBbhXVRQDJPkr9pGZvVjlVfXd+vyDcWPA2Ic9L2AXPIQM0+vk/cSQ==
@@ -909,7 +1103,7 @@ flat-cache@^3.0.4:
     flatted "^3.1.0"
     rimraf "^3.0.2"
 
-flatpickr@^4.6.13, flatpickr@4.6.13:
+flatpickr@4.6.13:
   version "4.6.13"
   resolved "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz"
   integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==
@@ -924,6 +1118,11 @@ fs.realpath@^1.0.0:
   resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
+fsevents@~2.3.2:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
+  integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
+
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
@@ -1089,7 +1288,7 @@ graphql-language-service@^5.0.6:
     nullthrows "^1.0.0"
     vscode-languageserver-types "^3.15.1"
 
-"graphql@^15.5.0 || ^16.0.0", "graphql@>= v14.5.0 <= 15.5.0", graphql@>=0.10.0:
+"graphql@>= v14.5.0 <= 15.5.0":
   version "15.5.0"
   resolved "https://registry.npmjs.org/graphql/-/graphql-15.5.0.tgz"
   integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA==
@@ -1121,12 +1320,7 @@ has-property-descriptors@^1.0.0:
   dependencies:
     get-intrinsic "^1.1.1"
 
-has-symbols@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz"
-  integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
-
-has-symbols@^1.0.2:
+has-symbols@^1.0.1, has-symbols@^1.0.2:
   version "1.0.2"
   resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz"
   integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
@@ -1268,14 +1462,7 @@ is-extglob@^2.1.1:
   resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"
   integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
 
-is-glob@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz"
-  integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
-  dependencies:
-    is-extglob "^2.1.1"
-
-is-glob@^4.0.1:
+is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1:
   version "4.0.1"
   resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz"
   integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
@@ -1289,13 +1476,6 @@ is-glob@^4.0.3:
   dependencies:
     is-extglob "^2.1.1"
 
-is-glob@~4.0.1:
-  version "4.0.1"
-  resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz"
-  integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
-  dependencies:
-    is-extglob "^2.1.1"
-
 is-negative-zero@^2.0.2:
   version "2.0.2"
   resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz"
@@ -1417,11 +1597,6 @@ json5@^1.0.1:
   dependencies:
     minimist "^1.2.0"
 
-just-debounce-it@^3.1.1:
-  version "3.1.1"
-  resolved "https://registry.npmjs.org/just-debounce-it/-/just-debounce-it-3.1.1.tgz"
-  integrity sha512-oPsuRyWp99LJaQ4KXC3A42tQNqkRTcPy0A8BCkRZ5cPCgsx81upB2KUrmHZvDUNhnCDKe7MshfTuWFQB9iXwDg==
-
 levn@^0.4.1:
   version "0.4.1"
   resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz"
@@ -1521,11 +1696,6 @@ minimist@^1.2.6:
   resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz"
   integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
 
-ms@^2.1.1:
-  version "2.1.3"
-  resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
-  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
-
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
@@ -1536,22 +1706,16 @@ ms@2.1.2:
   resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
   integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
 
+ms@^2.1.1:
+  version "2.1.3"
+  resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
+  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
 natural-compare@^1.4.0:
   version "1.4.0"
   resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
   integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
 
-"netbox-graphiql@file:/home/jstretch/projects/netbox/netbox/project-static/netbox-graphiql":
-  version "0.1.0"
-  resolved "file:netbox-graphiql"
-  dependencies:
-    graphiql "1.8.9"
-    graphql ">= v14.5.0 <= 15.5.0"
-    react "17.0.2"
-    react-dom "17.0.2"
-    subscriptions-transport-ws "0.9.18"
-    whatwg-fetch "3.6.2"
-
 normalize-path@^3.0.0, normalize-path@~3.0.0:
   version "3.0.0"
   resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
@@ -1697,7 +1861,7 @@ prettier-linter-helpers@^1.0.0:
   dependencies:
     fast-diff "^1.1.2"
 
-prettier@^2.7.1, prettier@>=2.0.0:
+prettier@^2.7.1:
   version "2.7.1"
   resolved "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz"
   integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==
@@ -1722,7 +1886,7 @@ queue-microtask@^1.2.2:
   resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
   integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
 
-"react-dom@^16.8.0 || ^17.0.0 || ^18.0.0", react-dom@17.0.2:
+react-dom@17.0.2:
   version "17.0.2"
   resolved "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz"
   integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
@@ -1731,7 +1895,7 @@ queue-microtask@^1.2.2:
     object-assign "^4.1.1"
     scheduler "^0.20.2"
 
-"react@^16.8.0 || ^17.0.0 || ^18.0.0", react@17.0.2:
+react@17.0.2:
   version "17.0.2"
   resolved "https://registry.npmjs.org/react/-/react-17.0.2.tgz"
   integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
@@ -1878,11 +2042,6 @@ slash@^4.0.0:
   resolved "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz"
   integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
 
-slim-select@^1.27.1:
-  version "1.27.1"
-  resolved "https://registry.npmjs.org/slim-select/-/slim-select-1.27.1.tgz"
-  integrity sha512-LvJ02cKKk6/jSHIcQv7dZwkQSXHLCVQR3v3lo8RJUssUUcmKPkpBmTpQ8au8KSMkxwca9+yeg+dO0iHAaVr5Aw==
-
 "source-map-js@>=0.6.2 <2.0.0":
   version "1.0.2"
   resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz"
@@ -2004,6 +2163,14 @@ toggle-selection@^1.0.6:
   resolved "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz"
   integrity sha1-bkWxJj8gF/oKzH2J14sVuL932jI=
 
+tom-select@^2.3.1:
+  version "2.3.1"
+  resolved "https://registry.npmjs.org/tom-select/-/tom-select-2.3.1.tgz"
+  integrity sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg==
+  dependencies:
+    "@orchidjs/sifter" "^1.0.3"
+    "@orchidjs/unicode-variants" "^1.0.4"
+
 tsconfig-paths@^3.14.1:
   version "3.14.1"
   resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz"
@@ -2053,7 +2220,7 @@ typeface-roboto-mono@^1.1.13:
   resolved "https://registry.npmjs.org/typeface-roboto-mono/-/typeface-roboto-mono-1.1.13.tgz"
   integrity sha512-pnzDc70b7ywJHin/BUFL7HZX8DyOTBLT2qxlJ92eH1UJOFcENIBXa9IZrxsJX/gEKjbEDKhW5vz/TKRBNk/ufQ==
 
-"typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta", typescript@~4.8.4:
+typescript@~4.8.4:
   version "4.8.4"
   resolved "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz"
   integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==
@@ -2073,6 +2240,11 @@ unbox-primitive@^1.0.2:
     has-symbols "^1.0.3"
     which-boxed-primitive "^1.0.2"
 
+undici-types@~5.26.4:
+  version "5.26.5"
+  resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
+  integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
+
 uri-js@^4.2.2:
   version "4.4.1"
   resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz"

+ 1 - 1
netbox/templates/django/forms/widgets/select.html

@@ -1,4 +1,4 @@
-<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="{% if 'size' in widget.attrs %}form-select form-select-sm{% else %}netbox-static-select{% endif %}{% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}">{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
+<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="form-select {% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}">{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
   <optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
   {% include option.template_name with widget=option %}{% endfor %}{% if group_name %}
   </optgroup>{% endif %}{% endfor %}

+ 2 - 4
netbox/users/forms/model_forms.py

@@ -186,8 +186,7 @@ class UserForm(forms.ModelForm):
     object_permissions = DynamicModelMultipleChoiceField(
         required=False,
         label=_('Permissions'),
-        queryset=ObjectPermission.objects.all(),
-        to_field_name='pk',
+        queryset=ObjectPermission.objects.all()
     )
 
     fieldsets = (
@@ -244,8 +243,7 @@ class GroupForm(forms.ModelForm):
     object_permissions = DynamicModelMultipleChoiceField(
         required=False,
         label=_('Permissions'),
-        queryset=ObjectPermission.objects.all(),
-        to_field_name='pk',
+        queryset=ObjectPermission.objects.all()
     )
 
     fieldsets = (

+ 1 - 21
netbox/utilities/forms/fields/dynamic.py

@@ -64,8 +64,6 @@ class DynamicModelChoiceMixin:
         null_option: The string used to represent a null selection (if any)
         disabled_indicator: The name of the field which, if populated, will disable selection of the
             choice (optional)
-        fetch_trigger: The event type which will cause the select element to
-            fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional)
         selector: Include an advanced object selection widget to assist the user in identifying the desired object
     """
     filter = django_filters.ModelChoiceFilter
@@ -79,8 +77,6 @@ class DynamicModelChoiceMixin:
             initial_params=None,
             null_option=None,
             disabled_indicator=None,
-            fetch_trigger=None,
-            empty_label=None,
             selector=False,
             **kwargs
     ):
@@ -89,24 +85,12 @@ class DynamicModelChoiceMixin:
         self.initial_params = initial_params or {}
         self.null_option = null_option
         self.disabled_indicator = disabled_indicator
-        self.fetch_trigger = fetch_trigger
         self.selector = selector
 
-        # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
-        # by widget_attrs()
-        self.to_field_name = kwargs.get('to_field_name')
-        self.empty_option = empty_label or ""
-
         super().__init__(queryset, **kwargs)
 
     def widget_attrs(self, widget):
-        attrs = {
-            'data-empty-option': self.empty_option
-        }
-
-        # Set value-field attribute if the field specifies to_field_name
-        if self.to_field_name:
-            attrs['value-field'] = self.to_field_name
+        attrs = {}
 
         # Set the string used to represent a null option
         if self.null_option is not None:
@@ -116,10 +100,6 @@ class DynamicModelChoiceMixin:
         if self.disabled_indicator is not None:
             attrs['disabled-indicator'] = self.disabled_indicator
 
-        # Set the fetch trigger, if any.
-        if self.fetch_trigger is not None:
-            attrs['data-fetch-trigger'] = self.fetch_trigger
-
         # Attach any static query parameters
         if (len(self.query_params) > 0):
             widget.add_query_params(self.query_params)

+ 2 - 6
netbox/utilities/forms/widgets/apiselect.py

@@ -24,7 +24,7 @@ class APISelect(forms.Select):
     def __init__(self, api_url=None, full=False, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        self.attrs['class'] = 'netbox-api-select'
+        self.attrs['class'] = 'api-select'
         self.dynamic_params: Dict[str, List[str]] = {}
         self.static_params: Dict[str, List[str]] = {}
 
@@ -153,8 +153,4 @@ class APISelect(forms.Select):
 
 
 class APISelectMultiple(APISelect, forms.SelectMultiple):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        self.attrs['data-multiple'] = 1
+    pass

+ 1 - 2
netbox/utilities/forms/widgets/select.py

@@ -25,7 +25,6 @@ class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
             ('2', 'Yes'),
             ('3', 'No'),
         )
-        self.attrs['class'] = 'netbox-static-select'
 
 
 class ColorSelect(forms.Select):
@@ -37,7 +36,7 @@ class ColorSelect(forms.Select):
     def __init__(self, *args, **kwargs):
         kwargs['choices'] = add_blank_choice(ColorChoices)
         super().__init__(*args, **kwargs)
-        self.attrs['class'] = 'netbox-color-select'
+        self.attrs['class'] = 'color-select'
 
 
 class HTMXSelect(forms.Select):

+ 1 - 2
netbox/vpn/forms/model_forms.py

@@ -423,8 +423,7 @@ class L2VPNTerminationForm(NetBoxModelForm):
         queryset=L2VPN.objects.all(),
         required=True,
         query_params={},
-        label=_('L2VPN'),
-        fetch_trigger='open'
+        label=_('L2VPN')
     )
     vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),

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