Explorar o código

Improve APISelect query parameter handling (#7040)

* Fixes #7035: Refactor APISelect query_param logic

* Add filter_fields to extras.ObjectVar & fix default value handling

* Update ObjectVar docs to reflect new filter_fields attribute

* Revert changes from 89b7f3f

* Maintain current `query_params` API for form fields, transform data structure in widget

* Revert changes from d0208d4
Matt Love %!s(int64=4) %!d(string=hai) anos
pai
achega
25d1fe2c8d

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/config.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/config.js.map


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/jobs.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/jobs.js.map


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/lldp.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/lldp.js.map


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.js.map


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/status.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/status.js.map


+ 107 - 175
netbox/project-static/src/select/api.ts → netbox/project-static/src/select/api/apiSelect.ts

@@ -1,71 +1,26 @@
-import queryString from 'query-string';
-import debounce from 'just-debounce-it';
 import { readableColor } from 'color2k';
 import { readableColor } from 'color2k';
+import debounce from 'just-debounce-it';
+import queryString from 'query-string';
 import SlimSelect from 'slim-select';
 import SlimSelect from 'slim-select';
-import { createToast } from '../bs';
-import { hasUrl, hasExclusions, isTrigger } from './util';
+import { createToast } from '../../bs';
+import { hasUrl, hasExclusions, isTrigger } from '../util';
+import { DynamicParamsMap } from './dynamicParams';
+import { isStaticParams } from './types';
 import {
 import {
-  isTruthy,
   hasMore,
   hasMore,
+  isTruthy,
   hasError,
   hasError,
   getElement,
   getElement,
   getApiData,
   getApiData,
   isApiError,
   isApiError,
-  getElements,
   createElement,
   createElement,
   uniqueByProperty,
   uniqueByProperty,
   findFirstAdjacent,
   findFirstAdjacent,
-} from '../util';
+} from '../../util';
 
 
 import type { Stringifiable } from 'query-string';
 import type { Stringifiable } from 'query-string';
 import type { Option } from 'slim-select/dist/data';
 import type { Option } 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`.
- */
-type QueryFilter = Map<string, 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`.
- */
-type PathFilter = Map<string, Stringifiable>;
-
-/**
- * Merge or replace incoming options with current options.
- */
-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';
-
-// Various one-off patterns to replace in query param keys.
-const REPLACE_PATTERNS = [
-  // Don't query `termination_a_device=1`, but rather `device=1`.
-  [new RegExp(/termination_(a|b)_(.+)/g), '$2'],
-  // A tenant's group relationship field is `group`, but the field name is `tenant_group`.
-  [new RegExp(/tenant_(group)/g), '$1'],
-] as [RegExp, string][];
+import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types';
 
 
 // Empty placeholder option.
 // Empty placeholder option.
 const PLACEHOLDER = {
 const PLACEHOLDER = {
@@ -81,7 +36,7 @@ const DISABLED_ATTRIBUTES = ['occupied'] as string[];
  * Manage a single API-backed select element's state. Each API select element is likely controlled
  * 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.
  * or dynamically updated by one or more other API select (or static select) elements' values.
  */
  */
-class APISelect {
+export class APISelect {
   /**
   /**
    * Base `<select/>` DOM element.
    * Base `<select/>` DOM element.
    */
    */
@@ -124,23 +79,31 @@ class APISelect {
    */
    */
   private readonly slim: InstanceType<typeof SlimSelect>;
   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
    * 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:
    * updated as other dependent fields' values change. This is a mapping of:
    *
    *
-   *     Form Field Names → Form Field Values
+   *     Form Field Names → Object containing:
+   *                         - Query parameter key name
+   *                         - Query value
    *
    *
-   * 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`.
+   * 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 filterParams: QueryFilter = new Map();
+  private readonly dynamicParams: DynamicParamsMap = new DynamicParamsMap();
 
 
   /**
   /**
-   * Post-parsed URL query parameters for API queries.
+   * API query parameters that are already known by the server and should not change.
    */
    */
-  private readonly queryParams: QueryFilter = new Map();
+  private readonly staticParams: QueryFilter = new Map();
 
 
   /**
   /**
    * Mapping of URL template key/value pairs. If this element's URL contains Django template tags
    * Mapping of URL template key/value pairs. If this element's URL contains Django template tags
@@ -228,20 +191,21 @@ class APISelect {
     });
     });
 
 
     // Initialize API query properties.
     // Initialize API query properties.
-    this.getFilteredBy();
+    this.getStaticParams();
+    this.getDynamicParams();
     this.getPathKeys();
     this.getPathKeys();
 
 
-    for (const filter of this.filterParams.keys()) {
-      this.updateQueryParams(filter);
+    // Populate static query parameters.
+    for (const [key, value] of this.staticParams.entries()) {
+      this.queryParams.set(key, value);
     }
     }
 
 
-    // Add any already-resolved key/value pairs to the API query parameters.
-    for (const [key, value] of this.filterParams.entries()) {
-      if (isTruthy(value)) {
-        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()) {
     for (const filter of this.pathValues.keys()) {
       this.updatePathValues(filter);
       this.updatePathValues(filter);
     }
     }
@@ -395,7 +359,8 @@ class APISelect {
 
 
     // Create a unique iterator of all possible form fields which, when changed, should cause this
     // Create a unique iterator of all possible form fields which, when changed, should cause this
     // element to update its API query.
     // element to update its API query.
-    const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
+    // 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) {
     for (const dep of dependencies) {
       const filterElement = document.querySelector(`[name="${dep}"]`);
       const filterElement = document.querySelector(`[name="${dep}"]`);
@@ -588,6 +553,7 @@ class APISelect {
     this.updateQueryParams(target.name);
     this.updateQueryParams(target.name);
     this.updatePathValues(target.name);
     this.updatePathValues(target.name);
     this.updateQueryUrl();
     this.updateQueryUrl();
+
     // Load new data.
     // Load new data.
     Promise.all([this.loadData()]);
     Promise.all([this.loadData()]);
   }
   }
@@ -655,27 +621,12 @@ class APISelect {
    * Update an element's API URL based on the value of another element on which this element
    * Update an element's API URL based on the value of another element on which this element
    * relies.
    * relies.
    *
    *
-   * @param id DOM ID of the other element.
+   * @param fieldName DOM ID of the other element.
    */
    */
-  private updateQueryParams(id: string): void {
-    let key = id.replaceAll(/^id_/gi, '');
+  private updateQueryParams(fieldName: string): void {
     // Find the element dependency.
     // Find the element dependency.
-    const element = getElement<HTMLSelectElement>(`id_${key}`);
+    const element = document.querySelector<HTMLSelectElement>(`[name="${fieldName}"]`);
     if (element !== null) {
     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;
-        }
-      }
-
-      // Force related keys to end in `_id`, if they don't already.
-      if (key.substring(key.length - 3) !== '_id') {
-        key = `${key}_id`;
-      }
-
       // Initialize the element value as an array, in case there are multiple values.
       // Initialize the element value as an array, in case there are multiple values.
       let elementValue = [] as Stringifiable[];
       let elementValue = [] as Stringifiable[];
 
 
@@ -694,13 +645,38 @@ class APISelect {
 
 
       if (elementValue.length > 0) {
       if (elementValue.length > 0) {
         // If the field has a value, add it to the map.
         // If the field has a value, add it to the map.
-        if (this.filterParams.has(id)) {
-          // If this instance is filtered by the neighbor element, add its value to the map.
-          this.queryParams.set(key, elementValue);
+        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 {
       } else {
         // Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
         // Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
-        this.queryParams.delete(key);
+        const queryParam = this.dynamicParams.queryParam(fieldName);
+        if (queryParam !== null) {
+          this.queryParams.delete(queryParam);
+        }
       }
       }
     }
     }
   }
   }
@@ -796,88 +772,50 @@ class APISelect {
   }
   }
 
 
   /**
   /**
-   * Determine if a select element should be filtered by the value of another select element.
+   * Determine if a this instances' options 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>"]`
+   * 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.
    *
    *
-   * 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'));
-
-    /**
-     * Properly handle preexistence of keys, value types, and deduplication when adding a filter to
-     * `filterParams`.
-     *
-     * _Note: This is an unnamed function so that it can access `this`._
-     */
-    const addFilter = (key: string, value: Stringifiable): void => {
-      const current = this.filterParams.get(key);
-
-      if (typeof current !== 'undefined') {
-        // This instance is already filtered by `key`, so we should add the new `value`.
-        // Merge and deduplicate the current filter parameter values with the incoming value.
-        const next = Array.from(
-          new Set<Stringifiable>([...(current as Stringifiable[]), value]),
-        );
-        this.filterParams.set(key, next);
-      } else {
-        // This instance is not already filtered by `key`, so we should add a new mapping.
-        if (value === '') {
-          // Don't add placeholder values.
-          this.filterParams.set(key, []);
-        } else {
-          // If the value is not a placeholder, add it.
-          this.filterParams.set(key, [value]);
-        }
-      }
-    };
-
-    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)) {
-              // Query param contains multiple values.
-              for (const item of parsed) {
-                if (item.match(/^\$.+$/g)) {
-                  // Value is an unfulfilled variable.
-                  addFilter(item.replaceAll(pattern, ''), '');
-                } else {
-                  // Value has been fulfilled and is a real value to query.
-                  addFilter(key.replaceAll(keyPattern, ''), item);
-                }
-              }
-            } else {
-              if (parsed.match(/^\$.+$/g)) {
-                // Value is an unfulfilled variable.
-                addFilter(parsed.replaceAll(pattern, ''), '');
-              } else {
-                // Value has been fulfilled and is a real value to query.
-                addFilter(key.replaceAll(keyPattern, ''), parsed);
-              }
-            }
-          } catch (err) {
-            console.warn(err);
-            if (value.match(/^\$.+$/g)) {
-              // Value is an unfulfilled variable.
-              addFilter(value.replaceAll(pattern, ''), '');
+   * 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 {
             } else {
-              // Value has been fulfilled and is a real value to query.
-              addFilter(key.replaceAll(keyPattern, ''), value);
+              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();
     }
     }
   }
   }
 
 
@@ -990,9 +928,3 @@ class APISelect {
     }
     }
   }
   }
 }
 }
-
-export function initApiSelect(): void {
-  for (const select of getElements<HTMLSelectElement>('.netbox-api-select')) {
-    new APISelect(select);
-  }
-}

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

@@ -0,0 +1,76 @@
+import { isTruthy } from '../../util';
+import { isDataDynamicParams } from './types';
+
+import type { QueryParam } from './types';
+
+/**
+ * Extension of built-in `Map` to add convenience functions.
+ */
+export class DynamicParamsMap extends Map<string, QueryParam> {
+  /**
+   * Get the query parameter key based on field name.
+   *
+   * @param fieldName Related field name.
+   * @returns `queryParam` key.
+   */
+  public queryParam(fieldName: string): Nullable<QueryParam['queryParam']> {
+    const value = this.get(fieldName);
+    if (typeof value !== 'undefined') {
+      return value.queryParam;
+    }
+    return null;
+  }
+
+  /**
+   * Get the query parameter value based on field name.
+   *
+   * @param fieldName Related field name.
+   * @returns `queryValue` value, or an empty array if there is no corresponding Map entry.
+   */
+  public queryValue(fieldName: string): QueryParam['queryValue'] {
+    const value = this.get(fieldName);
+    if (typeof value !== 'undefined') {
+      return value.queryValue;
+    }
+    return [];
+  }
+
+  /**
+   * Update the value of a field when the value changes.
+   *
+   * @param fieldName Related field name.
+   * @param queryValue New value.
+   * @returns `true` if the update was successful, `false` if there was no corresponding Map entry.
+   */
+  public updateValue(fieldName: string, queryValue: QueryParam['queryValue']): boolean {
+    const current = this.get(fieldName);
+    if (isTruthy(current)) {
+      const { queryParam } = current;
+      this.set(fieldName, { queryParam, queryValue });
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Populate the underlying map based on the JSON passed in the `data-dynamic-params` attribute.
+   *
+   * @param json Raw JSON string from `data-dynamic-params` attribute.
+   */
+  public addFromJson(json: string | null | undefined): void {
+    if (isTruthy(json)) {
+      const deserialized = JSON.parse(json);
+      // Ensure the value is the data structure we expect.
+      if (isDataDynamicParams(deserialized)) {
+        for (const { queryParam, fieldName } of deserialized) {
+          // Populate the underlying map with the initial data.
+          this.set(fieldName, { queryParam, queryValue: [] });
+        }
+      } else {
+        throw new Error(
+          `Data from 'data-dynamic-params' attribute is improperly formatted: '${json}'`,
+        );
+      }
+    }
+  }
+}

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

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

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

@@ -0,0 +1,189 @@
+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[]>;
+
+/**
+ * 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;
+}

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

@@ -39,6 +39,8 @@ export function isTruthy<V extends unknown>(value: V): value is NonNullable<V> {
     return true;
     return true;
   } else if (typeof value === 'boolean') {
   } else if (typeof value === 'boolean') {
     return true;
     return true;
+  } else if (typeof value === 'object' && value !== null) {
+    return true;
   }
   }
   return false;
   return false;
 }
 }

+ 4 - 4
netbox/utilities/forms/fields.py

@@ -375,8 +375,8 @@ class DynamicModelChoiceMixin:
     filter = django_filters.ModelChoiceFilter
     filter = django_filters.ModelChoiceFilter
     widget = widgets.APISelect
     widget = widgets.APISelect
 
 
-    def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, fetch_trigger=None, *args,
-                 **kwargs):
+    def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, fetch_trigger=None,
+                 *args, **kwargs):
         self.query_params = query_params or {}
         self.query_params = query_params or {}
         self.initial_params = initial_params or {}
         self.initial_params = initial_params or {}
         self.null_option = null_option
         self.null_option = null_option
@@ -409,8 +409,8 @@ class DynamicModelChoiceMixin:
             attrs['data-fetch-trigger'] = self.fetch_trigger
             attrs['data-fetch-trigger'] = self.fetch_trigger
 
 
         # Attach any static query parameters
         # Attach any static query parameters
-        for key, value in self.query_params.items():
-            widget.add_query_param(key, value)
+        if (len(self.query_params) > 0):
+            widget.add_query_params(self.query_params)
 
 
         return attrs
         return attrs
 
 

+ 121 - 12
netbox/utilities/forms/widgets.py

@@ -1,4 +1,5 @@
 import json
 import json
+from typing import Dict, Sequence, List, Tuple, Union
 
 
 from django import forms
 from django import forms
 from django.conf import settings
 from django.conf import settings
@@ -26,6 +27,11 @@ __all__ = (
     'TimePicker',
     'TimePicker',
 )
 )
 
 
+JSONPrimitive = Union[str, bool, int, float, None]
+QueryParamValue = Union[JSONPrimitive, Sequence[JSONPrimitive]]
+QueryParam = Dict[str, QueryParamValue]
+ProcessedParams = Sequence[Dict[str, Sequence[JSONPrimitive]]]
+
 
 
 class SmallTextarea(forms.Textarea):
 class SmallTextarea(forms.Textarea):
     """
     """
@@ -135,29 +141,132 @@ class APISelect(SelectWithDisabled):
 
 
     :param api_url: API endpoint URL. Required if not set automatically by the parent field.
     :param api_url: API endpoint URL. Required if not set automatically by the parent field.
     """
     """
+
+    dynamic_params: Dict[str, str]
+    static_params: Dict[str, List[str]]
+
     def __init__(self, api_url=None, full=False, *args, **kwargs):
     def __init__(self, api_url=None, full=False, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         self.attrs['class'] = 'netbox-api-select'
         self.attrs['class'] = 'netbox-api-select'
+        self.dynamic_params: Dict[str, List[str]] = {}
+        self.static_params: Dict[str, List[str]] = {}
+
         if api_url:
         if api_url:
             self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/'))  # Inject BASE_PATH
             self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/'))  # Inject BASE_PATH
 
 
-    def add_query_param(self, name, value):
+    def _process_query_param(self, key: str, value: JSONPrimitive) -> None:
         """
         """
-        Add details for an additional query param in the form of a data-* JSON-encoded list attribute.
-
-        :param name: The name of the query param
-        :param value: The value of the query param
+        Based on query param value's type and value, update instance's dynamic/static params.
         """
         """
-        key = f'data-query-param-{name}'
-
-        values = json.loads(self.attrs.get(key, '[]'))
-        if type(value) in (list, tuple):
-            values.extend([str(v) for v in value])
+        if isinstance(value, str):
+            # Coerce `True` boolean.
+            if value.lower() == 'true':
+                value = True
+            # Coerce `False` boolean.
+            elif value.lower() == 'false':
+                value = False
+            # Query parameters cannot have a `None` (or `null` in JSON) type, convert
+            # `None` types to `'null'` so that ?key=null is used in the query URL.
+            elif value is None:
+                value = 'null'
+
+        # Check type of `value` again, since it may have changed.
+        if isinstance(value, str):
+            if value.startswith('$'):
+                # A value starting with `$` indicates a dynamic query param, where the
+                # initial value is unknown and will be updated at the JavaScript layer
+                # as the related form field's value changes.
+                field_name = value.strip('$')
+                self.dynamic_params[field_name] = key
+            else:
+                # A value _not_ starting with `$` indicates a static query param, where
+                # the value is already known and should not be changed at the JavaScript
+                # layer.
+                if key in self.static_params:
+                    current = self.static_params[key]
+                    self.static_params[key] = [*current, value]
+                else:
+                    self.static_params[key] = [value]
         else:
         else:
-            values.append(str(value))
+            # Any non-string values are passed through as static query params, since
+            # dynamic query param values have to be a string (in order to start with
+            # `$`).
+            if key in self.static_params:
+                current = self.static_params[key]
+                self.static_params[key] = [*current, value]
+            else:
+                self.static_params[key] = [value]
+
+    def _process_query_params(self, query_params: QueryParam) -> None:
+        """
+        Process an entire query_params dictionary, and handle primitive or list values.
+        """
+        for key, value in query_params.items():
+            if isinstance(value, (List, Tuple)):
+                # If value is a list/tuple, iterate through each item.
+                for item in value:
+                    self._process_query_param(key, item)
+            else:
+                self._process_query_param(key, value)
+
+    def _serialize_params(self, key: str, params: ProcessedParams) -> None:
+        """
+        Serialize dynamic or static query params to JSON and add the serialized value to
+        the widget attributes by `key`.
+        """
+        # Deserialize the current serialized value from the widget, using an empty JSON
+        # array as a fallback in the event one is not defined.
+        current = json.loads(self.attrs.get(key, '[]'))
+
+        # Combine the current values with the updated values and serialize the result as
+        # JSON. Note: the `separators` kwarg effectively removes extra whitespace from
+        # the serialized JSON string, which is ideal since these will be passed as
+        # attributes to HTML elements and parsed on the client.
+        self.attrs[key] = json.dumps([*current, *params], separators=(',', ':'))
 
 
-        self.attrs[key] = json.dumps(values)
+    def _add_dynamic_params(self) -> None:
+        """
+        Convert post-processed dynamic query params to data structure expected by front-
+        end, serialize the value to JSON, and add it to the widget attributes.
+        """
+        key = 'data-dynamic-params'
+        if len(self.dynamic_params) > 0:
+            try:
+                update = [{'fieldName': f, 'queryParam': q} for (f, q) in self.dynamic_params.items()]
+                self._serialize_params(key, update)
+            except IndexError as error:
+                raise RuntimeError(f"Missing required value for dynamic query param: '{self.dynamic_params}'") from error
+
+    def _add_static_params(self) -> None:
+        """
+        Convert post-processed static query params to data structure expected by front-
+        end, serialize the value to JSON, and add it to the widget attributes.
+        """
+        key = 'data-static-params'
+        if len(self.static_params) > 0:
+            try:
+                update = [{'queryParam': k, 'queryValue': v} for (k, v) in self.static_params.items()]
+                self._serialize_params(key, update)
+            except IndexError as error:
+                raise RuntimeError(f"Missing required value for static query param: '{self.static_params}'") from error
+
+    def add_query_params(self, query_params: QueryParam) -> None:
+        """
+        Proccess & add a dictionary of URL query parameters to the widget attributes.
+        """
+        # Process query parameters. This populates `self.dynamic_params` and `self.static_params`.
+        self._process_query_params(query_params)
+        # Add processed dynamic parameters to widget attributes.
+        self._add_dynamic_params()
+        # Add processed static parameters to widget attributes.
+        self._add_static_params()
+
+    def add_query_param(self, key: str, value: QueryParamValue) -> None:
+        """
+        Process & add a key/value pair of URL query parameters to the widget attributes.
+        """
+        self.add_query_params({key: value})
 
 
 
 
 class APISelectMultiple(APISelect, forms.SelectMultiple):
 class APISelectMultiple(APISelect, forms.SelectMultiple):

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio