Răsfoiți Sursa

Fixes #6990: Fix query param and query filter handling in API select

Matt 4 ani în urmă
părinte
comite
a3d5e04946

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/config.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/config.js.map


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/jobs.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/jobs.js.map


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/lldp.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/lldp.js.map


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox.js.map


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/status.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/status.js.map


+ 93 - 20
netbox/project-static/src/select/api.ts

@@ -17,12 +17,34 @@ import {
   findFirstAdjacent,
 } from '../util';
 
+import type { Stringifiable } from 'query-string';
 import type { Option } from 'slim-select/dist/data';
 
-type QueryFilter = Map<string, string | number | boolean>;
+/**
+ * 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.
@@ -40,11 +62,9 @@ export type Trigger =
 // 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_id'],
+  [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_id'],
-  // Append `_id` to any fields
-  [new RegExp(/^([A-Za-z0-9]+)(_id)?$/g), '$1_id'],
+  [new RegExp(/tenant_(group)/g), '$1'],
 ] as [RegExp, string][];
 
 // Empty placeholder option.
@@ -130,7 +150,7 @@ class APISelect {
    * `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();
+  private readonly pathValues: PathFilter = new Map();
 
   /**
    * Original API query URL passed via the `data-href` attribute from the server. This is kept so
@@ -226,7 +246,7 @@ class APISelect {
       this.updatePathValues(filter);
     }
 
-    this.queryParams.set('brief', true);
+    this.queryParams.set('brief', [true]);
     this.updateQueryUrl();
 
     // Initialize element styling.
@@ -608,7 +628,7 @@ class APISelect {
   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<string | number | boolean>;
+    const query = {} as Dict<Stringifiable[]>;
     for (const [key, value] of this.queryParams.entries()) {
       query[key] = value;
     }
@@ -651,11 +671,32 @@ class APISelect {
         }
       }
 
-      if (isTruthy(element.value)) {
+      // 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.
+      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.
         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);
+          // If this instance is filtered by the neighbor element, add its value to the map.
+          this.queryParams.set(key, elementValue);
         }
       } else {
         // Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
@@ -771,6 +812,34 @@ class APISelect {
       .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);
@@ -778,29 +847,33 @@ class APISelect {
           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)) {
-                  const replaced = item.replaceAll(pattern, '');
-                  this.filterParams.set(replaced, '');
+                  // Value is an unfulfilled variable.
+                  addFilter(item.replaceAll(pattern, ''), '');
                 } else {
-                  this.filterParams.set(key.replaceAll(keyPattern, ''), item);
+                  // Value has been fulfilled and is a real value to query.
+                  addFilter(key.replaceAll(keyPattern, ''), item);
                 }
               }
             } else {
               if (parsed.match(/^\$.+$/g)) {
-                const replaced = parsed.replaceAll(pattern, '');
-                this.filterParams.set(replaced, '');
+                // Value is an unfulfilled variable.
+                addFilter(parsed.replaceAll(pattern, ''), '');
               } else {
-                this.filterParams.set(key.replaceAll(keyPattern, ''), parsed);
+                // 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)) {
-              const replaced = value.replaceAll(pattern, '');
-              this.filterParams.set(replaced, '');
+              // Value is an unfulfilled variable.
+              addFilter(value.replaceAll(pattern, ''), '');
             } else {
-              this.filterParams.set(key.replaceAll(keyPattern, ''), value);
+              // Value has been fulfilled and is a real value to query.
+              addFilter(key.replaceAll(keyPattern, ''), value);
             }
           }
         }

+ 4 - 4
netbox/project-static/src/util.ts

@@ -46,11 +46,11 @@ export function slugify(slug: string, chars: number): string {
 /**
  * Type guard to determine if a value is not null, undefined, or empty.
  */
-export function isTruthy<V extends string | number | boolean | null | undefined>(
-  value: V,
-): value is NonNullable<V> {
+export function isTruthy<V extends unknown>(value: V): value is NonNullable<V> {
   const badStrings = ['', 'null', 'undefined'];
-  if (typeof value === 'string' && !badStrings.includes(value)) {
+  if (Array.isArray(value)) {
+    return value.length > 0;
+  } else if (typeof value === 'string' && !badStrings.includes(value)) {
     return true;
   } else if (typeof value === 'number') {
     return true;

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff