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

fix issue where select fields with a pre-populated value were reset when forms were submitted, due to having the disabled attribute set.

checktheroads 4 лет назад
Родитель
Сommit
21db209f47

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


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


+ 40 - 42
netbox/project-static/src/forms.ts

@@ -43,6 +43,44 @@ function initSpeedSelector(): void {
   }
 }
 
+function handleFormSubmit(event: Event, form: HTMLFormElement): void {
+  // Track the names of each invalid field.
+  const invalids = new Set<string>();
+
+  for (const element of form.querySelectorAll<FormControls>('*[name]')) {
+    if (!element.validity.valid) {
+      invalids.add(element.name);
+
+      // If the field is invalid, but contains the .is-valid class, remove it.
+      if (element.classList.contains('is-valid')) {
+        element.classList.remove('is-valid');
+      }
+      // If the field is invalid, but doesn't contain the .is-invalid class, add it.
+      if (!element.classList.contains('is-invalid')) {
+        element.classList.add('is-invalid');
+      }
+    } else {
+      // If the field is valid, but contains the .is-invalid class, remove it.
+      if (element.classList.contains('is-invalid')) {
+        element.classList.remove('is-invalid');
+      }
+      // If the field is valid, but doesn't contain the .is-valid class, add it.
+      if (!element.classList.contains('is-valid')) {
+        element.classList.add('is-valid');
+      }
+    }
+  }
+
+  if (invalids.size !== 0) {
+    // If there are invalid fields, pick the first field and scroll to it.
+    const firstInvalid = form.elements.namedItem(Array.from(invalids)[0]) as Element;
+    scrollTo(firstInvalid);
+
+    // If the form has invalid fields, don't submit it.
+    event.preventDefault();
+  }
+}
+
 /**
  * Attach an event listener to each form's submitter (button[type=submit]). When called, the
  * callback checks the validity of each form field and adds the appropriate Bootstrap CSS class
@@ -50,53 +88,13 @@ function initSpeedSelector(): void {
  */
 function initFormElements() {
   for (const form of getElements('form')) {
-    const { elements } = form;
     // Find each of the form's submitters. Most object edit forms have a "Create" and
     // a "Create & Add", so we need to add a listener to both.
-    const submitters = form.querySelectorAll('button[type=submit]');
-
-    function callback(event: Event): void {
-      // Track the names of each invalid field.
-      const invalids = new Set<string>();
-
-      for (const el of elements) {
-        const element = (el as unknown) as FormControls;
-
-        if (!element.validity.valid) {
-          invalids.add(element.name);
-
-          // If the field is invalid, but contains the .is-valid class, remove it.
-          if (element.classList.contains('is-valid')) {
-            element.classList.remove('is-valid');
-          }
-          // If the field is invalid, but doesn't contain the .is-invalid class, add it.
-          if (!element.classList.contains('is-invalid')) {
-            element.classList.add('is-invalid');
-          }
-        } else {
-          // If the field is valid, but contains the .is-invalid class, remove it.
-          if (element.classList.contains('is-invalid')) {
-            element.classList.remove('is-invalid');
-          }
-          // If the field is valid, but doesn't contain the .is-valid class, add it.
-          if (!element.classList.contains('is-valid')) {
-            element.classList.add('is-valid');
-          }
-        }
-      }
-
-      if (invalids.size !== 0) {
-        // If there are invalid fields, pick the first field and scroll to it.
-        const firstInvalid = elements.namedItem(Array.from(invalids)[0]) as Element;
-        scrollTo(firstInvalid);
+    const submitters = form.querySelectorAll<HTMLButtonElement>('button[type=submit]');
 
-        // If the form has invalid fields, don't submit it.
-        event.preventDefault();
-      }
-    }
     for (const submitter of submitters) {
       // Add the event listener to each submitter.
-      submitter.addEventListener('click', callback);
+      submitter.addEventListener('click', event => handleFormSubmit(event, form));
     }
   }
 }

+ 121 - 136
netbox/project-static/src/select/api.ts

@@ -2,12 +2,12 @@ import SlimSelect from 'slim-select';
 import queryString from 'query-string';
 import { getApiData, isApiError, getElements, isTruthy, hasError } from '../util';
 import { createToast } from '../bs';
-import { setOptionStyles, getFilteredBy, toggle } from './util';
+import { setOptionStyles, toggle, getDependencyIds } from './util';
 
 import type { Option } from 'slim-select/dist/data';
 
 type WithUrl = {
-  url: string;
+  'data-url': string;
 };
 
 type WithExclude = {
@@ -16,18 +16,16 @@ type WithExclude = {
 
 type ReplaceTuple = [RegExp, string];
 
-interface CustomSelect<T extends Record<string, string>> extends HTMLSelectElement {
-  dataset: T;
-}
+type CustomSelect<T extends Record<string, string>> = HTMLSelectElement & T;
 
-function isCustomSelect(el: HTMLSelectElement): el is CustomSelect<WithUrl> {
-  return typeof el?.dataset?.url === 'string';
+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> {
-  return (
-    typeof el?.dataset?.queryParamExclude === 'string' && el?.dataset?.queryParamExclude !== ''
-  );
+  const exclude = el.getAttribute('data-query-param-exclude');
+  return typeof exclude === 'string' && exclude !== '';
 }
 
 const DISABLED_ATTRIBUTES = ['occupied'] as string[];
@@ -68,65 +66,71 @@ async function getOptions(
   // 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 => {
-    if (hasError(data)) {
-      if (isApiError(data)) {
-        createToast('danger', data.exception, data.error).show();
-        return [PLACEHOLDER];
-      }
-      createToast('danger', `Error Fetching Options for field ${select.name}`, data.error).show();
+    .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];
     }
+    createToast('danger', `Error Fetching Options for field ${select.name}`, data.error).show();
+    return [PLACEHOLDER];
+  }
 
-    const { results } = data;
-    const options = [PLACEHOLDER] as Option[];
-
-    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);
-        }
-      }
-
-      let style, selected, disabled;
+  const { results } = data;
+  const options = [PLACEHOLDER] as Option[];
 
-      // Set pre-selected options.
-      if (selectOptions.includes(value)) {
-        selected = true;
-      }
+  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;
 
-      // Set option to disabled if it is contained within the disabled array.
-      if (selectOptions.some(option => disabledOptions.includes(option))) {
-        disabled = true;
+    // 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 => Object.keys(result).includes(key) && result[key])) {
-        disabled = true;
+      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;
+        }
       }
+    }
 
-      const option = {
-        value,
-        text,
-        data,
-        style,
-        selected,
-        disabled,
-      } as Option;
+    // Set option to disabled if it is contained within the disabled array.
+    if (selectOptions.some(option => disabledOptions.includes(option))) {
+      disabled = true;
+    }
 
-      options.push(option);
+    // 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;
     }
-    return options;
-  });
+
+    const option = {
+      value,
+      text,
+      data,
+      style,
+      selected,
+      disabled,
+    } as Option;
+
+    options.push(option);
+  }
+  return options;
 }
 
 /**
@@ -175,27 +179,27 @@ function getDisplayName(result: APIObjectBase, select: HTMLSelectElement): strin
  */
 export function initApiSelect() {
   for (const select of getElements<HTMLSelectElement>('.netbox-select2-api')) {
-    const filterMap = getFilteredBy(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 = {} as Record<string, string>;
-    // List of OTHER elements THIS element relies on for query filtering.
-    const groupBy = [] as HTMLSelectElement[];
 
-    if (isCustomSelect(select)) {
+    if (hasUrl(select)) {
       // Store the original URL, so it can be referred back to as filter-by elements change.
-      const originalUrl = JSON.parse(JSON.stringify(select.dataset.url)) as string;
-      // Unpack the original URL with the intent of reassigning it as context updates.
-      let { url } = select.dataset;
+      // 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.dataset.queryParamExclude) as string[];
+          const exclusions = JSON.parse(
+            select.getAttribute('data-query-param-exclude') ?? '[]',
+          ) as string[];
           disabledOptions = [...disabledOptions, ...exclusions];
         } catch (err) {
           console.warn(
@@ -207,7 +211,7 @@ export function initApiSelect() {
       const instance = new SlimSelect({
         select,
         allowDeselect: true,
-        deselectLabel: `<i class="bi bi-x-circle" style="color:currentColor;"></i>`,
+        deselectLabel: `<i class="mdi mdi-close-circle" style="color:currentColor;"></i>`,
         placeholder,
         onChange() {
           const element = instance.slim.container ?? null;
@@ -233,42 +237,52 @@ export function initApiSelect() {
         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);
+      /**
+       * 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);
             }
           }
-        }
-
-        // 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);
-            break;
+          if (isTruthy(element.value)) {
+            // Add the dependency's value to the URL query.
+            query[key] = element.value;
           }
         }
-
-        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;
-        }
+      }
+      // 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 });
 
       /**
@@ -279,64 +293,35 @@ export function initApiSelect() {
        */
       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);
-              break;
-            }
-          }
-
-          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 });
-        } else {
-          url = originalUrl;
-        }
+        // 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);
           });
-        // 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);
+      for (const dep of dependencies) {
+        const element = document.getElementById(`id_${dep}`);
+        if (element !== null) {
+          element.addEventListener('change', handleEvent);
+        }
+        select.addEventListener(`netbox.select.onload.${dep}`, handleEvent);
       }
 
       // Load data.
       getOptions(url, select, disabledOptions)
         .then(options => instance.setData(options))
+        .catch(console.error)
         .finally(() => {
           // Set option styles, if the field calls for it (color selectors).
           setOptionStyles(instance);

+ 1 - 1
netbox/project-static/src/select/color.ts

@@ -34,7 +34,7 @@ export function initColorSelect(): void {
       select,
       allowDeselect: true,
       // Inherit the calculated color on the deselect icon.
-      deselectLabel: `<i class="bi bi-x-circle" style="color: currentColor;"></i>`,
+      deselectLabel: `<i class="mdi mdi-close-circle" style="color: currentColor;"></i>`,
     });
 
     // Style the select container to match any pre-selectd options.

+ 1 - 1
netbox/project-static/src/select/static.ts

@@ -14,7 +14,7 @@ export function initStaticSelect() {
       const instance = new SlimSelect({
         select,
         allowDeselect: true,
-        deselectLabel: `<i class="bi bi-x-circle"></i>`,
+        deselectLabel: `<i class="mdi mdi-close-circle"></i>`,
         placeholder,
       });
 

+ 30 - 1
netbox/project-static/src/select/util.ts

@@ -63,7 +63,7 @@ export function setOptionStyles(instance: SlimSelect): void {
       const fg = readableColor(bg);
 
       // Add a unique identifier to the style element.
-      style.dataset.netbox = id;
+      style.setAttribute('data-netbox', id);
 
       // Scope the CSS to apply both the list item and the selected item.
       style.innerHTML = `
@@ -155,3 +155,32 @@ export function getFilteredBy<T extends HTMLElement>(element: T): Map<string, st
   }
   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', ''));
+}

+ 6 - 12
netbox/templates/generic/object_edit.html

@@ -3,17 +3,11 @@
 {% block title %}{% if obj.pk %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}{% endblock %}
 
 {% block controls %}
-{% if settings.DOCS_ROOT %}
-<button
-  type="button"
-  class="btn btn-sm btn-outline-secondary"
-  data-bs-toggle="modal"
-  data-bs-target="#docs_modal"
-  title="Help"
->
-  <i class="mdi mdi-help-circle"></i>
-</button>
-{% endif %}
+  {% if settings.DOCS_ROOT %}
+  <button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#docs_modal" title="Help">
+    <i class="mdi mdi-help-circle"></i>
+  </button>
+  {% endif %}
 {% endblock %}
 
 {% block content %}
@@ -26,7 +20,7 @@
       {% block form %}
       {% if form.Meta.fieldsets %}
 
-      {# Render grouped fields accoring to Form #} 
+      {# Render grouped fields according to Form #} 
       {% for group, fields in form.Meta.fieldsets %}
         <div class="field-group">
           <h4 class="mb-3">{{ group }}</h4>

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