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

#6372: Fix query issues with & refactor API select

checktheroads 4 лет назад
Родитель
Сommit
1661d1f4ca

+ 2 - 0
netbox/dcim/forms.py

@@ -2077,6 +2077,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             attrs={
                 'disabled-indicator': 'device',
                 'data-query-param-face': "[\"$face\"]",
+                # The UI will not sort this element's options.
+                'pre-sorted': ''
             }
         )
     )

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


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


+ 646 - 301
netbox/project-static/src/select/api.ts

@@ -1,41 +1,21 @@
-import SlimSelect from 'slim-select';
 import queryString from 'query-string';
+import { readableColor } from 'color2k';
+import SlimSelect from 'slim-select';
+import { createToast } from '../bs';
+import { hasUrl, hasExclusions } from './util';
 import {
+  isTruthy,
+  hasError,
+  getElement,
   getApiData,
   isApiError,
   getElements,
-  isTruthy,
-  hasError,
   findFirstAdjacent,
 } from '../util';
-import { createToast } from '../bs';
-import { setOptionStyles, toggle, getDependencyIds, initResetButton } from './util';
 
 import type { Option } from 'slim-select/dist/data';
 
-type WithUrl = {
-  'data-url': string;
-};
-
-type WithExclude = {
-  queryParamExclude: string;
-};
-
-type ReplaceTuple = [RegExp, string];
-
-type CustomSelect<T extends Record<string, string>> = HTMLSelectElement & T;
-
-function hasUrl(el: HTMLSelectElement): el is CustomSelect<WithUrl> {
-  const value = el.getAttribute('data-url');
-  return typeof value === 'string' && value !== '';
-}
-
-function hasExclusions(el: HTMLSelectElement): el is CustomSelect<WithExclude> {
-  const exclude = el.getAttribute('data-query-param-exclude');
-  return typeof exclude === 'string' && exclude !== '';
-}
-
-const DISABLED_ATTRIBUTES = ['occupied'] as string[];
+type QueryFilter = Map<string, string | number>;
 
 // Various one-off patterns to replace in query param keys.
 const REPLACE_PATTERNS = [
@@ -45,335 +25,700 @@ const REPLACE_PATTERNS = [
   [new RegExp(/tenant_(group)/g), '$1_id'],
   // Append `_id` to any fields
   [new RegExp(/^([A-Za-z0-9]+)(_id)?$/g), '$1_id'],
-] as ReplaceTuple[];
+] as [RegExp, string][];
 
+// Empty placeholder option.
 const PLACEHOLDER = {
   value: '',
   text: '',
   placeholder: true,
 } as Option;
 
+// Attributes which if truthy should render the option disabled.
+const DISABLED_ATTRIBUTES = ['occupied'] as string[];
+
 /**
- * Retrieve all objects for this object type.
- *
- * @param url API endpoint to query.
- *
- * @returns Data parsed into SlimSelect options.
+ * 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.
  */
-async function getOptions(
-  url: string,
-  select: HTMLSelectElement,
-  disabledOptions: string[],
-): Promise<Option[]> {
-  if (url.includes(`{{`)) {
-    return [PLACEHOLDER];
+class APISelect {
+  /**
+   * Base `<select/>` DOM element.
+   */
+  private readonly base: HTMLSelectElement;
+
+  /**
+   * Form field name.
+   */
+  public readonly name: string;
+
+  /**
+   * Form field placeholder.
+   */
+  public readonly placeholder: string;
+
+  /**
+   * This element's options come from the server pre-sorted and should not be sorted client-side.
+   * Determined by the existence of the `pre-sorted` attribute on the base `<select/>` element.
+   */
+  private readonly preSorted: boolean = false;
+
+  /**
+   * Event to be dispatched when dependent fields' values change.
+   */
+  private readonly loadEvent: InstanceType<typeof Event>;
+
+  /**
+   * SlimSelect instance for this element.
+   */
+  private readonly slim: InstanceType<typeof SlimSelect>;
+
+  /**
+   * 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 → Form Field Values
+   *
+   * This is/might be different than the query parameters themselves, as the form field names may
+   * be different than the object model key names. For example, `tenant_group` would be the field
+   * name, but `group` would be the query parameter. Query parameters themselves are tracked in
+   * `queryParams`.
+   */
+  private readonly filterParams: QueryFilter = new Map();
+
+  /**
+   * Post-parsed URL query parameters for API queries.
+   */
+  private readonly queryParams: 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: QueryFilter = 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 = '';
+
+  /**
+   * This instance's available options.
+   */
+  private _options: Option[] = [PLACEHOLDER];
+
+  /**
+   * Array of options values which should be considered disabled or static.
+   */
+  private disabledOptions: Array<string> = [];
+
+  constructor(base: HTMLSelectElement) {
+    // Initialize readonly properties.
+    this.base = base;
+    this.name = base.name;
+
+    if (base.getAttribute('pre-sorted') !== null) {
+      this.preSorted = true;
+    }
+
+    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.placeholder = this.getPlaceholder();
+    this.disabledOptions = this.getDisabledOptions();
+
+    this.slim = new SlimSelect({
+      select: this.base,
+      allowDeselect: true,
+      deselectLabel: `<i class="mdi mdi-close-circle" style="color:currentColor;"></i>`,
+      placeholder: this.placeholder,
+      onChange: () => this.handleSlimChange(),
+    });
+
+    // Initialize API query properties.
+    this.getFilteredBy();
+    this.getPathKeys();
+
+    for (const filter of this.filterParams.keys()) {
+      this.updateQueryParams(filter);
+    }
+
+    for (const filter of this.pathValues.keys()) {
+      this.updatePathValues(filter);
+    }
+
+    this.queryParams.set('limit', 0);
+    this.updateQueryUrl();
+
+    // Initialize element styling.
+    this.resetClasses();
+    this.setSlimStyles();
+
+    // Initialize controlling elements.
+    this.initResetButton();
+
+    // Add dependency event listeners.
+    this.addEventListeners();
+
+    // Determine if this element is part of collapsible element.
+    const collapse = findFirstAdjacent(this.base, '.collapse', '.content-container');
+    if (collapse !== null) {
+      // 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());
+    } else {
+      // Otherwise, load the data on render.
+      Promise.all([this.loadData()]);
+    }
   }
 
-  // Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
-  // existing object. When we fetch options from the API later, we can set any of the options
-  // contained in this array to `selected`.
-  const selectOptions = Array.from(select.options)
-    .map(option => option.getAttribute('value'))
-    .filter(isTruthy);
-
-  const data = await getApiData(url);
-  if (hasError(data)) {
-    if (isApiError(data)) {
-      createToast('danger', data.exception, data.error).show();
-      return [PLACEHOLDER];
+  /**
+   * This instance's available options.
+   */
+  public get options(): Option[] {
+    return this._options;
+  }
+
+  /**
+   * Sort incoming options by label and apply the new options to both the SlimSelect instance and
+   * this manager's state. If the `preSorted` attribute exists on the base `<select/>` element,
+   * the options will *not* be sorted.
+   */
+  private set options(optionsIn: Option[]) {
+    let newOptions = optionsIn;
+    if (!this.preSorted) {
+      newOptions = optionsIn.sort((a, b) => (a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1));
     }
-    createToast('danger', `Error Fetching Options for field ${select.name}`, data.error).show();
-    return [PLACEHOLDER];
+
+    this._options = newOptions;
+    this.slim.setData(newOptions);
   }
 
-  const { results } = data;
-  const options = [PLACEHOLDER] as Option[];
+  /**
+   * Remove all options and reset back to the generic placeholder.
+   */
+  private resetOptions(): void {
+    this.options = [PLACEHOLDER];
+  }
 
-  for (const result of results) {
-    const text = getDisplayName(result, select);
-    const data = {} as Record<string, string>;
-    const value = result.id.toString();
-    let style, selected, disabled;
+  /**
+   * 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', '');
+      }
+    }
+  }
 
-    // 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 = k.replaceAll('_', '-');
-        data[key] = String(v);
+  /**
+   * 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');
       }
-      // Set option to disabled if the result contains a matching key and is truthy.
-      if (DISABLED_ATTRIBUTES.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;
-        }
+    } else if (this.slim.slim.multiSelected !== null) {
+      if (this.slim.slim.multiSelected.container.hasAttribute('disabled')) {
+        this.slim.slim.multiSelected.container.removeAttribute('disabled');
       }
     }
+  }
 
-    // Set option to disabled if it is contained within the disabled array.
-    if (selectOptions.some(option => disabledOptions.includes(option))) {
-      disabled = true;
+  /**
+   * 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.filterParams.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));
     }
+  }
 
-    // Set pre-selected options.
-    if (selectOptions.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;
+  /**
+   * Load this element's options from the NetBox API.
+   */
+  private async loadData(): Promise<void> {
+    try {
+      this.disable();
+      await this.getOptions();
+    } catch (err) {
+      console.error(err);
+    } finally {
+      this.setOptionStyles();
+      this.enable();
+      this.base.dispatchEvent(this.loadEvent);
     }
+  }
 
-    const option = {
-      value,
-      text,
-      data,
-      style,
-      selected,
-      disabled,
-    } as Option;
+  /**
+   * Query the NetBox API for this element's options.
+   */
+  private async getOptions(): Promise<void> {
+    if (this.queryUrl.includes(`{{`)) {
+      this.options = [PLACEHOLDER];
+      return;
+    }
 
-    options.push(option);
-  }
-  return options;
-}
+    // Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
+    // existing object. When we fetch options from the API later, we can set any of the options
+    // contained in this array to `selected`.
+    const selectOptions = Array.from(this.base.options)
+      .filter(option => option.selected)
+      .map(option => option.getAttribute('value'))
+      .filter(isTruthy);
 
-/**
- * Find the select element's placeholder text/label.
- */
-function getPlaceholder(select: HTMLSelectElement): string {
-  let placeholder = select.name;
-  if (select.id) {
-    const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
-
-    // Set the placeholder text to the label value, if it exists.
-    if (label !== null) {
-      placeholder = `Select ${label.innerText.trim()}`;
+    const data = await getApiData(this.queryUrl);
+
+    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);
     }
-  }
-  return placeholder;
-}
 
-/**
- * Find this field's display name.
- * @param select
- * @returns
- */
-function getDisplayName(result: APIObjectBase, select: HTMLSelectElement): string {
-  let displayName = result.display;
+    const { results } = data;
+    const options = [PLACEHOLDER] as Option[];
 
-  const legacyDisplayProperty = select.getAttribute('display-field');
+    for (const result of results) {
+      const text = this.getDisplayName(result);
+      const data = {} as Record<string, string>;
+      const value = result.id.toString();
+      let style, selected, disabled;
 
-  if (
-    typeof displayName === 'undefined' &&
-    legacyDisplayProperty !== null &&
-    legacyDisplayProperty in result
-  ) {
-    displayName = result[legacyDisplayProperty] as string;
+      // 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 = k.replaceAll('_', '-');
+          data[key] = String(v);
+        }
+        // Set option to disabled if the result contains a matching key and is truthy.
+        if (DISABLED_ATTRIBUTES.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 (selectOptions.some(option => this.disabledOptions.includes(option))) {
+        disabled = true;
+      }
+
+      // Set pre-selected options.
+      if (selectOptions.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.push(option);
+    }
+    this.options = options;
   }
 
-  if (!displayName) {
-    displayName = result.name;
+  /**
+   * 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()]);
   }
 
-  return displayName;
-}
+  /**
+   * 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();
+  }
 
-/**
- * Initialize select elements that rely on the NetBox API to build their options.
- */
-export function initApiSelect() {
-  for (const select of getElements<HTMLSelectElement>('.netbox-api-select')) {
-    const dependencies = getDependencyIds(select);
-    // Initialize an event, so other elements relying on this element can subscribe to this
-    // element's value.
-    const event = new Event(`netbox.select.onload.${select.name}`);
-    // Query Parameters - will have attributes added below.
-    const query = { limit: 0 } as Record<string, string | number>;
-
-    if (hasUrl(select)) {
-      // Store the original URL, so it can be referred back to as filter-by elements change.
-      // const originalUrl = select.getAttribute('data-url') as string;
-      // Get the original URL with the intent of reassigning it as context updates.
-      let url = select.getAttribute('data-url') ?? '';
-
-      const placeholder = getPlaceholder(select);
-
-      let disabledOptions = [] as string[];
-      if (hasExclusions(select)) {
-        try {
-          const exclusions = JSON.parse(
-            select.getAttribute('data-query-param-exclude') ?? '[]',
-          ) as string[];
-          disabledOptions = [...disabledOptions, ...exclusions];
-        } catch (err) {
-          console.warn(
-            `Unable to parse data-query-param-exclude value on select element '${select.name}': ${err}`,
-          );
-        }
+  /**
+   * `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);
+  }
 
-      const instance = new SlimSelect({
-        select,
-        allowDeselect: true,
-        deselectLabel: `<i class="mdi mdi-close-circle" style="color:currentColor;"></i>`,
-        placeholder,
-        onChange() {
-          const element = instance.slim.container ?? null;
-          if (element !== null) {
-            // Reset validity classes if the field was invalid.
-            if (
-              element.classList.contains('is-invalid') ||
-              select.classList.contains('is-invalid')
-            ) {
-              select.classList.remove('is-invalid');
-              element.classList.remove('is-invalid');
-            }
-          }
-          select.dispatchEvent(event);
-        },
-      });
+  /**
+   * 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 Record<string, string | number>;
+    for (const [key, value] of this.queryParams.entries()) {
+      query[key] = value;
+    }
 
-      // Disable the element while data has not been loaded.
-      toggle('disable', instance);
+    let url = this.url;
 
-      // Don't copy classes from select element to SlimSelect instance.
-      for (const className of select.classList) {
-        instance.slim.container.classList.remove(className);
+    // 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 = url.replaceAll(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 upon which this element
-       * relies.
-       *
-       * @param id DOM ID of the other element.
-       */
-      function updateQuery(id: string) {
-        let key = id;
-        // Find the element dependency.
-        const element = document.getElementById(`id_${id}`) as Nullable<HTMLSelectElement>;
-        if (element !== null) {
-          if (element.value !== '') {
-            // If the dependency has a value, parse the dependency's name (form key) for any
-            // required replacements.
-            for (const [pattern, replacement] of REPLACE_PATTERNS) {
-              if (id.match(pattern)) {
-                key = id.replaceAll(pattern, replacement);
-                break;
-              }
-            }
-            // 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/`.
-            if (url.includes(`{{`)) {
-              for (const test of url.matchAll(new RegExp(`({{(${id}|${key})}})`, 'g'))) {
-                // The template tag may contain the original element name or the post-parsed value.
-                url = url.replaceAll(test[1], element.value);
-              }
-              // Set the DOM attribute to reflect the change.
-              select.setAttribute('data-url', url);
-            }
-          }
-          if (isTruthy(element.value)) {
-            // Add the dependency's value to the URL query.
-            query[key] = element.value;
-          }
+  /**
+   * Update an element's API URL based on the value of another element on which this element
+   * relies.
+   *
+   * @param id DOM ID of the other element.
+   */
+  private updateQueryParams(id: string): void {
+    let key = id.replaceAll(/^id_/gi, '');
+    // Find the element dependency.
+    const element = getElement<HTMLSelectElement>(`id_${key}`);
+    if (element !== null) {
+      // If the dependency has a value, parse the dependency's name (form key) for any
+      // required replacements.
+      for (const [pattern, replacement] of REPLACE_PATTERNS) {
+        if (key.match(pattern)) {
+          key = key.replaceAll(pattern, replacement);
+          break;
         }
       }
-      // Process each of the dependencies, updating this element's URL or other attributes as
-      // needed.
-      for (const dep of dependencies) {
-        updateQuery(dep);
-      }
 
-      // Create a valid encoded URL with all query params.
-      url = queryString.stringifyUrl({ url, query });
-
-      /**
-       * When the group's selection changes, re-query the dependant element's options, but
-       * filtered to results matching the group's ID.
-       *
-       * @param event Group's DOM event.
-       */
-      function handleEvent(event: Event) {
-        const target = event.target as HTMLSelectElement;
-        // Update the element's URL after any changes to a dependency.
-        updateQuery(target.id);
-
-        // Disable the element while data is loading.
-        toggle('disable', instance);
-        // Load new data.
-        getOptions(url, select, disabledOptions)
-          .then(data => instance.setData(data))
-          .catch(console.error)
-          .finally(() => {
-            // Re-enable the element after data has loaded.
-            toggle('enable', instance);
-            // Inform any event listeners that data has updated.
-            select.dispatchEvent(event);
-          });
+      if (isTruthy(element.value)) {
+        // If the field has a value, add it to the map.
+        if (this.filterParams.has(id)) {
+          // If this element is tracking the neighbor element, add its value to the map.
+          this.queryParams.set(key, element.value);
+        }
+      } else {
+        // Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
+        this.queryParams.delete(key);
       }
+    }
+  }
 
-      for (const dep of dependencies) {
-        const element = document.getElementById(`id_${dep}`);
-        if (element !== null) {
-          element.addEventListener('change', handleEvent);
+  /**
+   * Update `pathValues` based on the form value of another element.
+   *
+   * @param id DOM ID of the other element.
+   */
+  private updatePathValues(id: string): void {
+    let key = id.replaceAll(/^id_/gi, '');
+    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, '');
         }
-        select.addEventListener(`netbox.select.onload.${dep}`, handleEvent);
       }
+    }
+  }
+
+  /**
+   * 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;
+  }
+
+  /**
+   * Find this element's display name.
+   */
+  private getDisplayName(result: APIObjectBase): string {
+    let displayName = result.display;
+
+    const legacyDisplayProperty = this.base.getAttribute('display-field');
+
+    if (
+      typeof displayName === 'undefined' &&
+      legacyDisplayProperty !== null &&
+      legacyDisplayProperty in result
+    ) {
+      displayName = result[legacyDisplayProperty] as string;
+    }
 
-      /**
-       * Load this element's options from the NetBox API.
-       */
-      async function loadData(): Promise<void> {
-        try {
-          const options = await getOptions(url, select, disabledOptions);
-          instance.setData(options);
-        } catch (err) {
-          console.error(err);
-        } finally {
-          setOptionStyles(instance);
-          toggle('enable', instance);
-          select.dispatchEvent(event);
+    if (!displayName) {
+      displayName = result.name;
+    }
+
+    return displayName;
+  }
+
+  /**
+   * 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;
+  }
+
+  /**
+   * 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 select element should be filtered by the value of another select element.
+   *
+   * Looks for the DOM attribute `data-query-param-<name of other field>`, which would look like:
+   * `["$<name>"]`
+   *
+   * If the attribute exists, parse out the raw value. In the above example, this would be `name`.
+   */
+  private getFilteredBy(): void {
+    const pattern = new RegExp(/\[|\]|"|\$/g);
+    const keyPattern = new RegExp(/data-query-param-/g);
+
+    // Extract data attributes.
+    const keys = Object.values(this.base.attributes)
+      .map(v => v.name)
+      .filter(v => v.includes('data'));
+
+    for (const key of keys) {
+      if (key.match(keyPattern) && key !== 'data-query-param-exclude') {
+        const value = this.base.getAttribute(key);
+        if (value !== null) {
+          try {
+            const parsed = JSON.parse(value) as string | string[];
+            if (Array.isArray(parsed)) {
+              for (const item of parsed) {
+                if (item.match(/^\$.+$/g)) {
+                  const replaced = item.replaceAll(pattern, '');
+                  this.filterParams.set(replaced, '');
+                } else {
+                  this.filterParams.set(key.replaceAll(keyPattern, ''), item);
+                }
+              }
+            } else {
+              if (parsed.match(/^\$.+$/g)) {
+                const replaced = parsed.replaceAll(pattern, '');
+                this.filterParams.set(replaced, '');
+              } else {
+                this.filterParams.set(key.replaceAll(keyPattern, ''), parsed);
+              }
+            }
+          } catch (err) {
+            console.warn(err);
+            if (value.match(/^\$.+$/g)) {
+              const replaced = value.replaceAll(pattern, '');
+              this.filterParams.set(replaced, '');
+            } else {
+              this.filterParams.set(key.replaceAll(keyPattern, ''), value);
+            }
+          }
         }
       }
+    }
+  }
+
+  /**
+   * 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 = `
+  div.ss-values div.ss-value[data-id="${id}"],
+  div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
+   {
+    background-color: ${bg} !important;
+    color: ${fg} !important;
+  }
+              `
+          .replaceAll('\n', '')
+          .trim();
 
-      /**
-       * Delete this element's options.
-       */
-      function clearData(): void {
-        return instance.setData([]);
+        // Add the style element to the DOM.
+        document.head.appendChild(style);
       }
+    }
+  }
 
-      // Determine if this element is part of collapsible element.
-      const collapse = findFirstAdjacent(select, '.collapse', '.content-container');
-      if (collapse !== null) {
-        // 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', loadData);
-        collapse.addEventListener('hide.bs.collapse', clearData);
-      } else {
-        // Otherwise, load the data on render.
-        Promise.all([loadData()]);
+  /**
+   * 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);
       }
+    }
+  }
 
-      // Bind event listener to
-      initResetButton(select, instance);
-
-      // Set the underlying select element to the same size as the SlimSelect instance.
-      // This is primarily for built-in HTML form validation, which doesn't really work,
-      // but it also makes things seem cleaner in the DOM.
-      const { width, height } = instance.slim.container.getBoundingClientRect();
-      select.style.opacity = '0';
-      select.style.width = `${width}px`;
-      select.style.height = `${height}px`;
-      select.style.display = 'block';
-      select.style.position = 'absolute';
-      select.style.pointerEvents = 'none';
+  /**
+   * Initialize any adjacent reset buttons so that when clicked, the instance's selected value is cleared.
+   */
+  private initResetButton(): void {
+    const resetButton = findFirstAdjacent<HTMLButtonElement>(this.base, 'button[data-reset-select');
+    if (resetButton !== null) {
+      resetButton.addEventListener('click', () => {
+        this.base.value = '';
+        if (this.base.multiple) {
+          this.slim.setSelected([]);
+        } else {
+          this.slim.setSelected('');
+        }
+      });
     }
   }
 }
+
+export function initApiSelect() {
+  for (const select of getElements<HTMLSelectElement>('.netbox-api-select')) {
+    new APISelect(select);
+  }
+}

+ 10 - 200
netbox/project-static/src/select/util.ts

@@ -1,207 +1,17 @@
-import { readableColor } from 'color2k';
-import { findFirstAdjacent } from '../util';
-
-import type SlimSelect from 'slim-select';
-
-/**
- * Add or remove a class to the SlimSelect element to match Bootstrap .form-select:disabled styles.
- *
- * @param action `enable` or `disable`
- * @param instance Instance of SlimSelect
- */
-export function toggle(action: 'enable' | 'disable', instance: SlimSelect): void {
-  if (action === 'enable') {
-    if (instance.slim.singleSelected !== null) {
-      if (instance.slim.singleSelected.container.hasAttribute('disabled')) {
-        instance.slim.singleSelected.container.removeAttribute('disabled');
-      }
-    } else if (instance.slim.multiSelected !== null) {
-      if (instance.slim.multiSelected.container.hasAttribute('disabled')) {
-        instance.slim.multiSelected.container.removeAttribute('disabled');
-      }
-    }
-  } else if (action === 'disable') {
-    if (instance.slim.singleSelected !== null) {
-      if (!instance.slim.singleSelected.container.hasAttribute('disabled')) {
-        instance.slim.singleSelected.container.setAttribute('disabled', '');
-      }
-    } else if (instance.slim.multiSelected !== null) {
-      if (!instance.slim.multiSelected.container.hasAttribute('disabled')) {
-        instance.slim.multiSelected.container.setAttribute('disabled', '');
-      }
-    }
-  }
-}
-
 /**
- * Add scoped style elements specific to each SlimSelect option, if the color property exists.
- * As of this writing, this attribute only exist on Tags. The color property is used as the
- * background color, and a foreground color is detected based on the luminosity of the background
- * color.
- *
- * @param instance SlimSelect instance with options already set.
+ * Determine if an element has the `data-url` attribute set.
  */
-export function setOptionStyles(instance: SlimSelect): void {
-  const options = instance.data.data;
-  for (const option of options) {
-    // Only create style elements for options that contain a color attribute.
-    if (
-      'data' in option &&
-      'id' in option &&
-      typeof option.data !== 'undefined' &&
-      typeof option.id !== 'undefined' &&
-      'color' in option.data
-    ) {
-      const id = option.id as string;
-      const data = option.data as { color: string };
-
-      // Create the style element.
-      const style = document.createElement('style');
-
-      // Append hash to color to make it a valid hex color.
-      const bg = `#${data.color}`;
-      // Detect the foreground color.
-      const fg = readableColor(bg);
-
-      // Add a unique identifier to the style element.
-      style.setAttribute('data-netbox', id);
-
-      // Scope the CSS to apply both the list item and the selected item.
-      style.innerHTML = `
-div.ss-values div.ss-value[data-id="${id}"],
-div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
- {
-  background-color: ${bg} !important;
-  color: ${fg} !important;
-}
-            `
-        .replaceAll('\n', '')
-        .trim();
-
-      // Add the style element to the DOM.
-      document.head.appendChild(style);
-    }
-  }
-}
-
-/**
- * Determine if a select element should be filtered by the value of another select element.
- *
- * Looks for the DOM attribute `data-query-param-<name of other field>`, which would look like:
- * `["$<name>"]`
- *
- * If the attribute exists, parse out the raw value. In the above example, this would be `name`.
- *
- * @param element Element to scan
- * @returns Map of attributes to values. An empty value indicates a dynamic property that will
- *          be updated later.
- */
-export function getFilteredBy<T extends HTMLElement>(element: T): Map<string, string> {
-  const pattern = new RegExp(/\[|\]|"|\$/g);
-  const keyPattern = new RegExp(/data-query-param-/g);
-
-  // Extract data attributes.
-  const keys = Object.values(element.attributes)
-    .map(v => v.name)
-    .filter(v => v.includes('data'));
-
-  const filterMap = new Map<string, string>();
-
-  // Process the URL attribute in a separate loop so that it comes first.
-  for (const key of keys) {
-    const url = element.getAttribute('data-url');
-    if (key === 'data-url' && url !== null && url.includes(`{{`)) {
-      // If the URL contains a Django/Jinja template variable tag we need to extract the variable
-      // name and consider this a field to monitor for changes.
-      const value = url.match(/\{\{(.+)\}\}/);
-      if (value !== null) {
-        filterMap.set(value[1], '');
-      }
-    }
-  }
-  for (const key of keys) {
-    if (key.match(keyPattern) && key !== 'data-query-param-exclude') {
-      const value = element.getAttribute(key);
-      if (value !== null) {
-        try {
-          const parsed = JSON.parse(value) as string | string[];
-          if (Array.isArray(parsed)) {
-            for (const item of parsed) {
-              if (item.match(/^\$.+$/g)) {
-                const replaced = item.replaceAll(pattern, '');
-                filterMap.set(replaced, '');
-              } else {
-                filterMap.set(key.replaceAll(keyPattern, ''), item);
-              }
-            }
-          } else {
-            if (parsed.match(/^\$.+$/g)) {
-              const replaced = parsed.replaceAll(pattern, '');
-              filterMap.set(replaced, '');
-            } else {
-              filterMap.set(key.replaceAll(keyPattern, ''), parsed);
-            }
-          }
-        } catch (err) {
-          console.warn(err);
-          if (value.match(/^\$.+$/g)) {
-            const replaced = value.replaceAll(pattern, '');
-            filterMap.set(replaced, '');
-          } else {
-            filterMap.set(key.replaceAll(keyPattern, ''), value);
-          }
-        }
-      }
-    }
-  }
-  return filterMap;
-}
-
-function* getAllDependencyIds<E extends HTMLElement>(element: Nullable<E>): Generator<string> {
-  const keyPattern = new RegExp(/data-query-param-/g);
-  if (element !== null) {
-    for (const attr of element.attributes) {
-      if (attr.name.startsWith('data-query-param') && attr.name !== 'data-query-param-exclude') {
-        const dep = attr.name.replaceAll(keyPattern, '');
-        yield dep;
-        for (const depNext of getAllDependencyIds(document.getElementById(`id_${dep}`))) {
-          yield depNext;
-        }
-      } else if (attr.name === 'data-url' && attr.value.includes(`{{`)) {
-        const value = attr.value.match(/\{\{(.+)\}\}/);
-        if (value !== null) {
-          const dep = value[1];
-          yield dep;
-          for (const depNext of getAllDependencyIds(document.getElementById(`id_${dep}`))) {
-            yield depNext;
-          }
-        }
-      }
-    }
-  }
-}
-
-export function getDependencyIds<E extends HTMLElement>(element: Nullable<E>): string[] {
-  const ids = new Set<string>(getAllDependencyIds(element));
-  return Array.from(ids).map(i => i.replaceAll('_id', ''));
+export function hasUrl(el: HTMLSelectElement): el is HTMLSelectElement & { 'data-url': string } {
+  const value = el.getAttribute('data-url');
+  return typeof value === 'string' && value !== '';
 }
 
 /**
- * Initialize any adjacent reset buttons so that when clicked, the instance's selected value is cleared.
- *
- * @param select Select Element
- * @param instance SlimSelect Instance
+ * Determine if an element has the `data-query-param-exclude` attribute set.
  */
-export function initResetButton(select: HTMLSelectElement, instance: SlimSelect) {
-  const resetButton = findFirstAdjacent<HTMLButtonElement>(select, 'button[data-reset-select');
-  if (resetButton !== null) {
-    resetButton.addEventListener('click', () => {
-      select.value = '';
-      if (select.multiple) {
-        instance.setSelected([]);
-      } else {
-        instance.setSelected('');
-      }
-    });
-  }
+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 !== '';
 }

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