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

update select to handle display property

checktheroads 5 лет назад
Родитель
Сommit
4e837c2770

+ 8 - 3
netbox/project-static/src/global.d.ts

@@ -1,3 +1,7 @@
+type Primitives = string | number | boolean | undefined | null;
+
+type JSONAble = Primitives | Primitives[] | { [k: string]: JSONAble } | JSONAble[];
+
 type Nullable<T> = T | null;
 type Nullable<T> = T | null;
 
 
 type APIAnswer<T> = {
 type APIAnswer<T> = {
@@ -16,18 +20,19 @@ type APIError = {
 
 
 type APIObjectBase = {
 type APIObjectBase = {
   id: number;
   id: number;
+  display?: string;
   name: string;
   name: string;
   url: string;
   url: string;
-  [k: string]: unknown;
+  [k: string]: JSONAble;
 };
 };
 
 
-interface APIReference {
+type APIReference = {
   id: number;
   id: number;
   name: string;
   name: string;
   slug: string;
   slug: string;
   url: string;
   url: string;
   _depth: number;
   _depth: number;
-}
+};
 
 
 interface ObjectWithGroup extends APIObjectBase {
 interface ObjectWithGroup extends APIObjectBase {
   group: Nullable<APIReference>;
   group: Nullable<APIReference>;

+ 221 - 145
netbox/project-static/src/select/api.ts

@@ -1,6 +1,6 @@
 import SlimSelect from 'slim-select';
 import SlimSelect from 'slim-select';
 import queryString from 'query-string';
 import queryString from 'query-string';
-import { getApiData, isApiError, getElements } from '../util';
+import { getApiData, isApiError, getElements, isTruthy } from '../util';
 import { createToast } from '../toast';
 import { createToast } from '../toast';
 import { setOptionStyles, getFilteredBy, toggle } from './util';
 import { setOptionStyles, getFilteredBy, toggle } from './util';
 
 
@@ -14,6 +14,8 @@ type WithExclude = {
   queryParamExclude: string;
   queryParamExclude: string;
 };
 };
 
 
+type ReplaceTuple = [RegExp, string];
+
 interface CustomSelect<T extends Record<string, string>> extends HTMLSelectElement {
 interface CustomSelect<T extends Record<string, string>> extends HTMLSelectElement {
   dataset: T;
   dataset: T;
 }
 }
@@ -30,6 +32,15 @@ function hasExclusions(el: HTMLSelectElement): el is CustomSelect<WithExclude> {
 
 
 const DISABLED_ATTRIBUTES = ['occupied'] as string[];
 const DISABLED_ATTRIBUTES = ['occupied'] as string[];
 
 
+// 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'],
+  // For example, a tenant's group relationship field is `group`, but the field name
+  // is `tenant_group`.
+  [new RegExp(/.+_(group)/g), '$1_id'],
+] as ReplaceTuple[];
+
 const PLACEHOLDER = {
 const PLACEHOLDER = {
   value: '',
   value: '',
   text: '',
   text: '',
@@ -43,15 +54,22 @@ const PLACEHOLDER = {
  *
  *
  * @returns Data parsed into SlimSelect options.
  * @returns Data parsed into SlimSelect options.
  */
  */
-async function getChoices(
+async function getOptions(
   url: string,
   url: string,
-  displayField: string,
-  selectOptions: string[],
+  select: HTMLSelectElement,
   disabledOptions: string[],
   disabledOptions: string[],
 ): Promise<Option[]> {
 ): Promise<Option[]> {
   if (url.includes(`{{`)) {
   if (url.includes(`{{`)) {
     return [PLACEHOLDER];
     return [PLACEHOLDER];
   }
   }
+
+  // Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
+  // existing object. When we fetch options from the API later, we can set any of the options
+  // contained in this array to `selected`.
+  const selectOptions = Array.from(select.options)
+    .filter(option => option.value !== '')
+    .map(option => option.value);
+
   return getApiData(url).then(data => {
   return getApiData(url).then(data => {
     if (isApiError(data)) {
     if (isApiError(data)) {
       const toast = createToast('danger', data.exception, data.error);
       const toast = createToast('danger', data.exception, data.error);
@@ -62,78 +80,110 @@ async function getChoices(
     const { results } = data;
     const { results } = data;
     const options = [PLACEHOLDER] as Option[];
     const options = [PLACEHOLDER] as Option[];
 
 
-    if (results.length !== 0) {
-      for (const result of results) {
-        const data = {} as Record<string, string>;
-        const value = result.id.toString();
+    for (const result of results) {
+      const text = getDisplayName(result, select);
+      const data = {} as Record<string, string>;
+      const value = result.id.toString();
 
 
-        // 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 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);
         }
         }
+      }
 
 
-        let style, selected, disabled;
+      let style, selected, disabled;
 
 
-        // Set pre-selected options.
-        if (selectOptions.includes(value)) {
-          selected = true;
-        }
+      // Set pre-selected options.
+      if (selectOptions.includes(value)) {
+        selected = true;
+      }
 
 
-        // Set option to disabled if it is contained within the disabled array.
-        if (selectOptions.some(option => disabledOptions.includes(option))) {
-          disabled = true;
-        }
+      // Set option to disabled if it is contained within the disabled array.
+      if (selectOptions.some(option => disabledOptions.includes(option))) {
+        disabled = true;
+      }
 
 
-        // Set option to disabled if the result contains a matching key and is truthy.
-        if (DISABLED_ATTRIBUTES.some(key => Object.keys(result).includes(key) && result[key])) {
-          disabled = true;
-        }
+      // Set option to disabled if the result contains a matching key and is truthy.
+      if (DISABLED_ATTRIBUTES.some(key => Object.keys(result).includes(key) && result[key])) {
+        disabled = true;
+      }
 
 
-        const choice = {
-          value,
-          text: result[displayField],
-          data,
-          style,
-          selected,
-          disabled,
-        } as Option;
+      const option = {
+        value,
+        text,
+        data,
+        style,
+        selected,
+        disabled,
+      } as Option;
 
 
-        options.push(choice);
-      }
+      options.push(option);
     }
     }
     return options;
     return options;
   });
   });
 }
 }
 
 
+/**
+ * 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()}`;
+    }
+  }
+  return placeholder;
+}
+
+/**
+ * Find this field's display name.
+ * @param select
+ * @returns
+ */
+function getDisplayName(result: APIObjectBase, select: HTMLSelectElement): string {
+  let displayName = result.display;
+
+  const legacyDisplayProperty = select.getAttribute('display-field');
+
+  if (
+    typeof displayName === 'undefined' &&
+    legacyDisplayProperty !== null &&
+    legacyDisplayProperty in result
+  ) {
+    displayName = result[legacyDisplayProperty] as string;
+  }
+
+  if (!displayName) {
+    displayName = result.name;
+  }
+
+  return displayName;
+}
+
+/**
+ * Initialize select elements that rely on the NetBox API to build their options.
+ */
 export function initApiSelect() {
 export function initApiSelect() {
   for (const select of getElements<HTMLSelectElement>('.netbox-select2-api')) {
   for (const select of getElements<HTMLSelectElement>('.netbox-select2-api')) {
-    // Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
-    // existing object. When we fetch options from the API later, we can set any of the options
-    // contained in this array to `selected`.
-    const selectOptions = Array.from(select.options)
-      .filter(option => option.value !== '')
-      .map(option => option.value);
-
-    const filteredBy = getFilteredBy(select);
-    const filterMap = new Map<string, string>();
-    const event = new Event(`netbox.select.load.${select.name}`);
+    const filterMap = getFilteredBy(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 = {} as Record<string, string>;
+    // List of OTHER elements THIS element relies on for query filtering.
+    const groupBy = [] as HTMLSelectElement[];
 
 
     if (isCustomSelect(select)) {
     if (isCustomSelect(select)) {
       let { url } = select.dataset;
       let { url } = select.dataset;
-      const displayField = select.getAttribute('display-field') ?? 'name';
 
 
-      let placeholder: string = 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 placeholder = getPlaceholder(select);
 
 
       let disabledOptions = [] as string[];
       let disabledOptions = [] as string[];
       if (hasExclusions(select)) {
       if (hasExclusions(select)) {
@@ -152,6 +202,20 @@ export function initApiSelect() {
         allowDeselect: true,
         allowDeselect: true,
         deselectLabel: `<i class="bi bi-x-circle" style="color:currentColor;"></i>`,
         deselectLabel: `<i class="bi bi-x-circle" style="color:currentColor;"></i>`,
         placeholder,
         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);
+        },
       });
       });
 
 
       // Disable the element while data has not been loaded.
       // Disable the element while data has not been loaded.
@@ -162,29 +226,115 @@ export function initApiSelect() {
         instance.slim.container.classList.remove(className);
         instance.slim.container.classList.remove(className);
       }
       }
 
 
+      for (let [key, value] of filterMap) {
+        if (value === '') {
+          // An empty value is set if the key contains a `$`, indicating reliance on another field.
+          const elem = document.querySelector(`[name=${key}]`) as HTMLSelectElement;
+          if (elem !== null) {
+            groupBy.push(elem);
+            if (elem.value !== '') {
+              // If the element's form value exists, add it to the map.
+              value = elem.value;
+              filterMap.set(key, elem.value);
+            }
+          }
+        }
+
+        // A non-empty value indicates a static query parameter.
+        for (const [pattern, replacement] of REPLACE_PATTERNS) {
+          // Check the query param key to see if we should modify it.
+          if (key.match(pattern)) {
+            key = key.replaceAll(pattern, replacement);
+          }
+        }
+
+        if (url.includes(`{{`) && value !== '') {
+          // If the URL contains a Django/Jinja template variable, we need to replace the
+          // tag with the event's value.
+          url = url.replaceAll(new RegExp(`{{${key}}}`, 'g'), value);
+          select.setAttribute('data-url', url);
+        }
+
+        // Add post-replaced key/value pairs to the query object.
+        if (isTruthy(value)) {
+          query[key] = value;
+        }
+      }
+
+      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;
+
+        if (isTruthy(target.value)) {
+          let name = target.name;
+
+          for (const [pattern, replacement] of REPLACE_PATTERNS) {
+            // Check the query param key to see if we should modify it.
+            if (name.match(pattern)) {
+              name = name.replaceAll(pattern, replacement);
+            }
+          }
+
+          if (url.includes(`{{`) && target.name && target.value) {
+            // If the URL (still) contains a Django/Jinja template variable, we need to replace
+            // the tag with the event's value.
+            url = url.replaceAll(new RegExp(`{{${target.name}}}`, 'g'), target.value);
+            select.setAttribute('data-url', url);
+          }
+
+          if (filterMap.get(target.name) === '') {
+            // Update empty filter map values now that there is a value.
+            filterMap.set(target.name, target.value);
+          }
+          // Add post-replaced key/value pairs to the query object.
+          query[name] = target.value;
+          // Create a URL with all relevant query parameters.
+          url = queryString.stringifyUrl({ url, query });
+        }
+
+        // Disable the element while data is loading.
+        toggle('disable', instance);
+        // Load new data.
+        getOptions(url, select, disabledOptions)
+          .then(data => instance.setData(data))
+          .finally(() => {
+            // Re-enable the element after data has loaded.
+            toggle('enable', instance);
+            // Inform any event listeners that data has updated.
+            select.dispatchEvent(event);
+          });
+        // Stop event bubbling.
+        event.preventDefault();
+      }
+
+      for (const group of groupBy) {
+        // Re-fetch data when the group changes.
+        group.addEventListener('change', handleEvent);
+
+        // Subscribe this instance (the child that relies on `group`) to any changes of the
+        // group's value, so we can re-render options.
+        select.addEventListener(`netbox.select.onload.${group.name}`, handleEvent);
+      }
+
       // Load data.
       // Load data.
-      getChoices(url, displayField, selectOptions, disabledOptions)
+      getOptions(url, select, disabledOptions)
         .then(options => instance.setData(options))
         .then(options => instance.setData(options))
         .finally(() => {
         .finally(() => {
           // Set option styles, if the field calls for it (color selectors).
           // Set option styles, if the field calls for it (color selectors).
           setOptionStyles(instance);
           setOptionStyles(instance);
-          // Inform any event listeners that data has updated.
-          select.dispatchEvent(event);
           // Enable the element after data has loaded.
           // Enable the element after data has loaded.
           toggle('enable', instance);
           toggle('enable', instance);
+          // Inform any event listeners that data has updated.
+          select.dispatchEvent(event);
         });
         });
 
 
-      // Reset validity classes if the field was invalid.
-      instance.onChange = () => {
-        const element = instance.slim.container ?? null;
-        if (element !== null) {
-          if (element.classList.contains('is-invalid') || select.classList.contains('is-invalid')) {
-            select.classList.remove('is-invalid');
-            element.classList.remove('is-invalid');
-          }
-        }
-      };
-
       // Set the underlying select element to the same size as the SlimSelect 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,
       // This is primarily for built-in HTML form validation, which doesn't really work,
       // but it also makes things seem cleaner in the DOM.
       // but it also makes things seem cleaner in the DOM.
@@ -195,80 +345,6 @@ export function initApiSelect() {
       select.style.display = 'block';
       select.style.display = 'block';
       select.style.position = 'absolute';
       select.style.position = 'absolute';
       select.style.pointerEvents = 'none';
       select.style.pointerEvents = 'none';
-
-      for (const filter of filteredBy) {
-        // Find element with the `name` attribute matching this element's filtered-by attribute.
-        const groupElem = document.querySelector(`[name=${filter}]`) as HTMLSelectElement;
-
-        if (groupElem !== null) {
-          if (groupElem.value) {
-            // Add the group's value to the filtered-by map.
-            filterMap.set(filter, groupElem.value);
-            // If the URL contains a Django/Jinja template variable tag, we need to replace the tag
-            // with the event's value.
-            if (url.includes(`{{`)) {
-              url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), groupElem.value);
-              select.setAttribute('data-url', url);
-            }
-          }
-          /**
-           * When the group's selection changes, re-query the dependant element's options, but
-           * filtered to results matching the group's ID.
-           *
-           * @param event Group's DOM event.
-           */
-          function handleEvent(event: Event) {
-            let filterUrl = url;
-
-            const target = event.target as HTMLSelectElement;
-
-            if (target.value) {
-              let filterValue = target.name;
-
-              if (typeof filterValue === 'undefined') {
-                filterMap.set(target.name, target.value);
-              }
-
-              if (url.includes(`{{`) && typeof filterValue !== 'undefined') {
-                // If the URL contains a Django/Jinja template variable tag, we need to replace
-                // the tag with the event's value.
-                url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), filterValue);
-                select.setAttribute('data-url', url);
-              }
-
-              let queryKey = filterValue;
-              if (filter?.includes('_group')) {
-                // For example, a tenant's group relationship field is `group`, but the field
-                // name is `tenant_group`.
-                queryKey = 'group';
-              }
-
-              if (typeof queryKey !== 'undefined') {
-                filterUrl = queryString.stringifyUrl({
-                  url,
-                  query: { [`${queryKey}_id`]: groupElem.value },
-                });
-              }
-            }
-
-            // Disable the element while data is loading.
-            toggle('disable', instance);
-            // Load new data.
-            getChoices(filterUrl, displayField, selectOptions, disabledOptions)
-              .then(data => instance.setData(data))
-              .finally(() => {
-                // Re-enable the element after data has loaded.
-                toggle('enable', instance);
-              });
-          }
-          // Re-fetch data when the group changes.
-          groupElem.addEventListener('change', handleEvent);
-
-          // Subscribe this instance (the child that relies on groupElem) to any changes of the
-          // group's value, so we can re-render options.
-          select.addEventListener(`netbox.select.onload.${groupElem.name}`, handleEvent);
-        }
-      }
     }
     }
   }
   }
 }
 }

+ 48 - 20
netbox/project-static/src/select/util.ts

@@ -92,38 +92,66 @@ div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
  * If the attribute exists, parse out the raw value. In the above example, this would be `name`.
  * If the attribute exists, parse out the raw value. In the above example, this would be `name`.
  *
  *
  * @param element Element to scan
  * @param element Element to scan
- * @returns Attribute name, or null if it was not found.
+ * @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): string[] {
+export function getFilteredBy<T extends HTMLElement>(element: T): Map<string, string> {
   const pattern = new RegExp(/\[|\]|"|\$/g);
   const pattern = new RegExp(/\[|\]|"|\$/g);
-  const keys = Object.keys(element.dataset);
-  const filteredBy = [] as string[];
+  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.
   // Process the URL attribute in a separate loop so that it comes first.
   for (const key of keys) {
   for (const key of keys) {
-    if (key === 'url' && element.dataset.url?.includes(`{{`)) {
-      /**
-       * If the URL contains a Django/Jinja template variable tag we need to extract the variable
-       * name and consider this a field to monitor for changes.
-       */
-      const value = element.dataset.url.match(/\{\{(.+)\}\}/);
+    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) {
       if (value !== null) {
-        filteredBy.push(value[1]);
+        filterMap.set(value[1], '');
       }
       }
     }
     }
   }
   }
   for (const key of keys) {
   for (const key of keys) {
-    if (key.includes('queryParam') && key !== 'queryParamExclude') {
-      const value = element.dataset[key];
-      if (typeof value !== 'undefined') {
-        const parsed = JSON.parse(value) as string | string[];
-        if (Array.isArray(parsed)) {
-          filteredBy.push(parsed[0].replaceAll(pattern, ''));
-        } else {
-          filteredBy.push(value.replaceAll(pattern, ''));
+    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 filteredBy;
+  return filterMap;
 }
 }

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

@@ -4,6 +4,23 @@ export function isApiError(data: Record<string, unknown>): data is APIError {
   return 'error' in data;
   return 'error' in data;
 }
 }
 
 
+/**
+ * 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> {
+  const badStrings = ['', 'null', 'undefined'];
+  if (typeof value === 'string' && !badStrings.includes(value)) {
+    return true;
+  } else if (typeof value === 'number') {
+    return true;
+  } else if (typeof value === 'boolean') {
+    return true;
+  }
+  return false;
+}
+
 /**
 /**
  * Retrieve the CSRF token from cookie storage.
  * Retrieve the CSRF token from cookie storage.
  */
  */