checktheroads 5 лет назад
Родитель
Сommit
d0cb7d843d
2 измененных файлов с 158 добавлено и 108 удалено
  1. 148 102
      netbox/project-static/src/select/api.ts
  2. 10 6
      netbox/project-static/src/select/static.ts

+ 148 - 102
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 } from '../util';
+import { getApiData, isApiError, getElements } from '../util';
 import { createToast } from '../toast';
 import { createToast } from '../toast';
 import { setOptionStyles, getFilteredBy } from './util';
 import { setOptionStyles, getFilteredBy } from './util';
 
 
@@ -28,15 +28,88 @@ function hasExclusions(el: HTMLSelectElement): el is CustomSelect<WithExclude> {
   );
   );
 }
 }
 
 
+const DISABLED_ATTRIBUTES = ['occupied'] as string[];
+
 const PLACEHOLDER = {
 const PLACEHOLDER = {
   value: '',
   value: '',
   text: '',
   text: '',
   placeholder: true,
   placeholder: true,
 } as Option;
 } as Option;
 
 
+/**
+ * Retrieve all objects for this object type.
+ *
+ * @param url API endpoint to query.
+ *
+ * @returns Data parsed into SlimSelect options.
+ */
+async function getChoices(
+  url: string,
+  displayField: string,
+  selectOptions: string[],
+  disabledOptions: string[],
+): Promise<Option[]> {
+  if (url.includes(`{{`)) {
+    return [PLACEHOLDER];
+  }
+  return getApiData(url).then(data => {
+    if (isApiError(data)) {
+      const toast = createToast('danger', data.exception, data.error);
+      toast.show();
+      return [PLACEHOLDER];
+    }
+
+    const { results } = data;
+    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();
+
+        // 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;
+
+        // 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 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;
+
+        options.push(choice);
+      }
+    }
+    return options;
+  });
+}
+
 export function initApiSelect() {
 export function initApiSelect() {
-  const elements = document.querySelectorAll<HTMLSelectElement>('.netbox-select2-api');
-  for (const select of elements) {
+  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
     // 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
     // existing object. When we fetch options from the API later, we can set any of the options
     // contained in this array to `selected`.
     // contained in this array to `selected`.
@@ -46,6 +119,7 @@ export function initApiSelect() {
 
 
     const filteredBy = getFilteredBy(select);
     const filteredBy = getFilteredBy(select);
     const filterMap = new Map<string, string>();
     const filterMap = new Map<string, string>();
+    const event = new Event(`netbox.select.load.${select.name}`);
 
 
     if (isCustomSelect(select)) {
     if (isCustomSelect(select)) {
       let { url } = select.dataset;
       let { url } = select.dataset;
@@ -60,9 +134,17 @@ export function initApiSelect() {
           placeholder = `Select ${label.innerText.trim()}`;
           placeholder = `Select ${label.innerText.trim()}`;
         }
         }
       }
       }
+
       let disabledOptions = [] as string[];
       let disabledOptions = [] as string[];
       if (hasExclusions(select)) {
       if (hasExclusions(select)) {
-        disabledOptions = JSON.parse(select.dataset.queryParamExclude) as string[];
+        try {
+          const exclusions = JSON.parse(select.dataset.queryParamExclude) as string[];
+          disabledOptions = [...disabledOptions, ...exclusions];
+        } catch (err) {
+          console.warn(
+            `Unable to parse data-query-param-exclude value on select element '${select.name}': ${err}`,
+          );
+        }
       }
       }
 
 
       const instance = new SlimSelect({
       const instance = new SlimSelect({
@@ -72,6 +154,20 @@ export function initApiSelect() {
         placeholder,
         placeholder,
       });
       });
 
 
+      // Don't copy classes from select element to SlimSelect instance.
+      for (const className of select.classList) {
+        instance.slim.container.classList.remove(className);
+      }
+
+      getChoices(url, displayField, selectOptions, disabledOptions)
+        .then(options => instance.setData(options))
+        .finally(() => {
+          // Set option styles, if the field calls for it (color selectors).
+          setOptionStyles(instance);
+          // Inform any event listeners that data has updated.
+          select.dispatchEvent(event);
+        });
+
       // Reset validity classes if the field was invalid.
       // Reset validity classes if the field was invalid.
       instance.onChange = () => {
       instance.onChange = () => {
         const element = instance.slim.container ?? null;
         const element = instance.slim.container ?? null;
@@ -83,11 +179,6 @@ export function initApiSelect() {
         }
         }
       };
       };
 
 
-      // Don't copy classes from select element to SlimSelect instance.
-      for (const className of select.classList) {
-        instance.slim.container.classList.remove(className);
-      }
-
       // 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.
@@ -99,66 +190,12 @@ export function initApiSelect() {
       select.style.position = 'absolute';
       select.style.position = 'absolute';
       select.style.pointerEvents = 'none';
       select.style.pointerEvents = 'none';
 
 
-      /**
-       * Retrieve all objects for this object type.
-       *
-       * @param choiceUrl Optionally override the URL for filtering. If not set, the URL
-       *                  from the DOM attributes is used.
-       * @returns Data parsed into Choices.JS Choices.
-       */
-      async function getChoices(choiceUrl: string = url): Promise<Option[]> {
-        if (choiceUrl.includes(`{{`)) {
-          return [PLACEHOLDER];
-        }
-        return getApiData(choiceUrl).then(data => {
-          if (isApiError(data)) {
-            const toast = createToast('danger', data.exception, data.error);
-            toast.show();
-            return [PLACEHOLDER];
-          }
-
-          const { results } = data;
-          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();
-              let style, selected;
-              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);
-                }
-              }
-              if (selectOptions.includes(value)) {
-                selected = true;
-              }
-
-              const choice = {
-                value,
-                text: result[displayField],
-                data,
-                style,
-                selected,
-              } as Option;
-
-              options.push(choice);
-            }
-          }
-          return options;
-        });
-      }
-
-      if (filteredBy.length !== 0) {
-        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;
+      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 !== null) {
+          if (groupElem.value) {
             // Add the group's value to the filtered-by map.
             // Add the group's value to the filtered-by map.
             filterMap.set(filter, groupElem.value);
             filterMap.set(filter, groupElem.value);
             // If the URL contains a Django/Jinja template variable tag, we need to replace the tag
             // If the URL contains a Django/Jinja template variable tag, we need to replace the tag
@@ -167,50 +204,59 @@ export function initApiSelect() {
               url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), groupElem.value);
               url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), groupElem.value);
               select.setAttribute('data-url', url);
               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: string | undefined;
-
-              const target = event.target as HTMLSelectElement;
-
-              if (target.value) {
-                let filterValue = filterMap.get(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';
-                }
+          }
+          /**
+           * 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({
                 filterUrl = queryString.stringifyUrl({
                   url,
                   url,
                   query: { [`${queryKey}_id`]: groupElem.value },
                   query: { [`${queryKey}_id`]: groupElem.value },
                 });
                 });
               }
               }
-
-              getChoices(filterUrl).then(data => instance.setData(data));
             }
             }
 
 
-            groupElem.addEventListener('change', handleEvent);
-            groupElem.addEventListener('change', handleEvent);
+            getChoices(filterUrl, displayField, selectOptions, disabledOptions).then(data =>
+              instance.setData(data),
+            );
           }
           }
+          // 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);
         }
         }
       }
       }
-
-      getChoices()
-        .then(data => instance.setData(data))
-        .finally(() => setOptionStyles(instance));
     }
     }
   }
   }
 }
 }

+ 10 - 6
netbox/project-static/src/select/static.ts

@@ -1,23 +1,27 @@
 import SlimSelect from 'slim-select';
 import SlimSelect from 'slim-select';
+import { getElements } from '../util';
 
 
 export function initStaticSelect() {
 export function initStaticSelect() {
-  const elements = document.querySelectorAll(
-    '.netbox-select2-static',
-  ) as NodeListOf<HTMLSelectElement>;
-
-  for (const select of elements) {
+  for (const select of getElements<HTMLSelectElement>('.netbox-select2-static')) {
     if (select !== null) {
     if (select !== null) {
       const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
       const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
+
       let placeholder;
       let placeholder;
       if (label !== null) {
       if (label !== null) {
         placeholder = `Select ${label.innerText.trim()}`;
         placeholder = `Select ${label.innerText.trim()}`;
       }
       }
-      new SlimSelect({
+
+      const instance = new SlimSelect({
         select,
         select,
         allowDeselect: true,
         allowDeselect: true,
         deselectLabel: `<i class="bi bi-x-circle"></i>`,
         deselectLabel: `<i class="bi bi-x-circle"></i>`,
         placeholder,
         placeholder,
       });
       });
+
+      // Don't copy classes from select element to SlimSelect instance.
+      for (const className of select.classList) {
+        instance.slim.container.classList.remove(className);
+      }
     }
     }
   }
   }
 }
 }