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

Closes #13283: Add context to dropdown options (#15104)

* Initial work on #13283

* Enable passing TomSelect HTML template attibutes on DynamicModelChoiceField

* Merge disabled_indicator into option_attrs

* Add support for annotating a numeric count on dropdown options

* Annotate parent object on relevant fields

* Improve rendering of color options

* Improve rendering of color options

* Rename option_attrs to context

* Expose option context on ObjectVar for custom scripts

* Document dropdown context variables
Jeremy Stretch 2 лет назад
Родитель
Сommit
20824ceb25

+ 17 - 0
docs/customization/custom-scripts.md

@@ -304,6 +304,7 @@ A particular object within NetBox. Each ObjectVar must specify a particular mode
 
 * `model` - The model class
 * `query_params` - A dictionary of query parameters to use when retrieving available options (optional)
+* `context` - A custom dictionary mapping template context variables to fields, used when rendering `<option>` elements within the dropdown menu (optional; see below)
 * `null_option` - A label representing a "null" or empty choice (optional)
 
 To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:
@@ -331,6 +332,22 @@ site = ObjectVar(
 )
 ```
 
+#### Context Variables
+
+Custom context variables can be passed to override the default attribute names or to display additional information, such as a parent object.
+
+| Name          | Default         | Description                                                                  |
+|---------------|-----------------|------------------------------------------------------------------------------|
+| `value`       | `"id"`          | The attribute which contains the option's value                              |
+| `label`       | `"display"`     | The attribute used as the option's human-friendly label                      |
+| `description` | `"description"` | The attribute to use as a description                                        |
+| `depth`[^1]   | `"_depth"`      | The attribute which indicates an object's depth within a recursive hierarchy |
+| `disabled`    | --              | The attribute which, if true, signifies that the option should be disabled   |
+| `parent`      | --              | The attribute which represents the object's parent object                    |
+| `count`[^1]   | --              | The attribute which contains a numeric count of related objects              |
+
+[^1]: The value of this attribute must be a positive integer
+
 ### MultiObjectVar
 
 Similar to `ObjectVar`, but allows for the selection of multiple objects.

+ 6 - 0
netbox/dcim/forms/bulk_edit.py

@@ -557,6 +557,9 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
         label=_('Device type'),
         queryset=DeviceType.objects.all(),
         required=False,
+        context={
+            'parent': 'manufacturer',
+        },
         query_params={
             'manufacturer_id': '$manufacturer'
         }
@@ -640,6 +643,9 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
         required=False,
         query_params={
             'manufacturer_id': '$manufacturer'
+        },
+        context={
+            'parent': 'manufacturer',
         }
     )
     status = forms.ChoiceField(

+ 9 - 3
netbox/dcim/forms/connections.py

@@ -30,7 +30,9 @@ def get_cable_form(a_type, b_type):
                     attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
                         queryset=term_cls.objects.all(),
                         label=term_cls._meta.verbose_name.title(),
-                        disabled_indicator='_occupied',
+                        context={
+                            'disabled': '_occupied',
+                        },
                         query_params={
                             'device_id': f'$termination_{cable_end}_device',
                             'kind': 'physical',  # Exclude virtual interfaces
@@ -52,7 +54,9 @@ def get_cable_form(a_type, b_type):
                     attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
                         queryset=term_cls.objects.all(),
                         label=_('Power Feed'),
-                        disabled_indicator='_occupied',
+                        context={
+                            'disabled': '_occupied',
+                        },
                         query_params={
                             'power_panel_id': f'$termination_{cable_end}_powerpanel',
                         }
@@ -72,7 +76,9 @@ def get_cable_form(a_type, b_type):
                     attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
                         queryset=term_cls.objects.all(),
                         label=_('Side'),
-                        disabled_indicator='_occupied',
+                        context={
+                            'disabled': '_occupied',
+                        },
                         query_params={
                             'circuit_id': f'$termination_{cable_end}_circuit',
                         }

+ 22 - 4
netbox/dcim/forms/model_forms.py

@@ -426,7 +426,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
         widget=APISelect(
             api_url='/api/dcim/racks/{{rack}}/elevation/',
             attrs={
-                'disabled-indicator': 'device',
+                'ts-disabled-field': 'device',
                 'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
             },
         )
@@ -434,6 +434,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
     device_type = DynamicModelChoiceField(
         label=_('Device type'),
         queryset=DeviceType.objects.all(),
+        context={
+            'parent': 'manufacturer',
+        },
         selector=True
     )
     role = DynamicModelChoiceField(
@@ -461,6 +464,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
         label=_('Virtual chassis'),
         queryset=VirtualChassis.objects.all(),
         required=False,
+        context={
+            'parent': 'master',
+        },
         selector=True
     )
     vc_position = forms.IntegerField(
@@ -568,6 +574,9 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
     module_type = DynamicModelChoiceField(
         label=_('Module type'),
         queryset=ModuleType.objects.all(),
+        context={
+            'parent': 'manufacturer',
+        },
         selector=True
     )
     comments = CommentField()
@@ -774,7 +783,10 @@ class VCMemberSelectForm(forms.Form):
 class ComponentTemplateForm(forms.ModelForm):
     device_type = DynamicModelChoiceField(
         label=_('Device type'),
-        queryset=DeviceType.objects.all()
+        queryset=DeviceType.objects.all(),
+        context={
+            'parent': 'manufacturer',
+        }
     )
 
     def __init__(self, *args, **kwargs):
@@ -789,12 +801,18 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
     device_type = DynamicModelChoiceField(
         label=_('Device type'),
         queryset=DeviceType.objects.all().all(),
-        required=False
+        required=False,
+        context={
+            'parent': 'manufacturer',
+        }
     )
     module_type = DynamicModelChoiceField(
         label=_('Module type'),
         queryset=ModuleType.objects.all(),
-        required=False
+        required=False,
+        context={
+            'parent': 'manufacturer',
+        }
     )
 
     def __init__(self, *args, **kwargs):

+ 4 - 1
netbox/extras/scripts.py

@@ -193,16 +193,19 @@ class ObjectVar(ScriptVariable):
 
     :param model: The NetBox model being referenced
     :param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional)
+    :param context: A custom dictionary mapping template context variables to fields, used when rendering <option>
+        elements within the dropdown menu (optional)
     :param null_option: The label to use as a "null" selection option (optional)
     """
     form_field = DynamicModelChoiceField
 
-    def __init__(self, model, query_params=None, null_option=None, *args, **kwargs):
+    def __init__(self, model, query_params=None, context=None, null_option=None, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
         self.field_attrs.update({
             'queryset': model.objects.all(),
             'query_params': query_params,
+            'context': context,
             'null_option': null_option,
         })
 

+ 7 - 1
netbox/ipam/forms/model_forms.py

@@ -267,14 +267,20 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
 
 class IPAddressForm(TenancyForm, NetBoxModelForm):
     interface = DynamicModelChoiceField(
-        label=_('Interface'),
         queryset=Interface.objects.all(),
         required=False,
+        context={
+            'parent': 'device',
+        },
         selector=True,
+        label=_('Interface'),
     )
     vminterface = DynamicModelChoiceField(
         queryset=VMInterface.objects.all(),
         required=False,
+        context={
+            'parent': 'virtual_machine',
+        },
         selector=True,
         label=_('Interface'),
     )

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


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


+ 43 - 3
netbox/project-static/src/select/classes/dynamicTomSelect.ts

@@ -31,6 +31,15 @@ export class DynamicTomSelect extends TomSelect {
     // Glean the REST API endpoint URL from the <select> element
     this.api_url = this.input.getAttribute('data-url') as string;
 
+    // Override any field names set as widget attributes
+    this.valueField = this.input.getAttribute('ts-value-field') || this.settings.valueField;
+    this.labelField = this.input.getAttribute('ts-label-field') || this.settings.labelField;
+    this.disabledField = this.input.getAttribute('ts-disabled-field') || this.settings.disabledField;
+    this.descriptionField = this.input.getAttribute('ts-description-field') || 'description';
+    this.depthField = this.input.getAttribute('ts-depth-field') || '_depth';
+    this.parentField = this.input.getAttribute('ts-parent-field') || null;
+    this.countField = this.input.getAttribute('ts-count-field') || null;
+
     // Set the null option (if any)
     const nullOption = this.input.getAttribute('data-null-option');
     if (nullOption) {
@@ -82,10 +91,20 @@ export class DynamicTomSelect extends TomSelect {
     // Make the API request
     fetch(url)
       .then(response => response.json())
-      .then(json => {
-          self.loadCallback(json.results, []);
+      .then(apiData => {
+        const results: Dict[] = apiData.results;
+        let options: Dict[] = []
+        for (let result of results) {
+          const option = self.getOptionFromData(result);
+          options.push(option);
+        }
+        return options;
+      })
+      // Pass the options to the callback function
+      .then(options => {
+        self.loadCallback(options, []);
       }).catch(()=>{
-          self.loadCallback([], []);
+        self.loadCallback([], []);
       });
 
   }
@@ -126,6 +145,27 @@ export class DynamicTomSelect extends TomSelect {
     return queryString.stringifyUrl({ url, query });
   }
 
+  // Compile TomOption data from an API result
+  getOptionFromData(data: Dict) {
+    let option: Dict = {
+      id: data[this.valueField],
+      display: data[this.labelField],
+      depth: data[this.depthField] || null,
+      description: data[this.descriptionField] || null,
+    };
+    if (data[this.parentField]) {
+      let parent: Dict = data[this.parentField] as Dict;
+      option['parent'] = parent[this.labelField];
+    }
+    if (data[this.countField]) {
+      option['count'] = data[this.countField];
+    }
+    if (data[this.disabledField]) {
+      option['disabled'] = data[this.disabledField];
+    }
+    return option
+  }
+
   /**
    * Transitional methods
    */

+ 27 - 8
netbox/project-static/src/select/dynamic.ts

@@ -10,12 +10,34 @@ const MAX_OPTIONS = 100;
 
 // Render the HTML for a dropdown option
 function renderOption(data: TomOption, escape: typeof escape_html) {
-  // If the option has a `_depth` property, indent its label
-  if (typeof data._depth === 'number' && data._depth > 0) {
-    return `<div>${'─'.repeat(data._depth)} ${escape(data[LABEL_FIELD])}</div>`;
+  let html = '<div>';
+
+  // If the option has a `depth` property, indent its label
+  if (typeof data.depth === 'number' && data.depth > 0) {
+    html = `${html}${'─'.repeat(data.depth)} `;
+  }
+
+  html = `${html}${escape(data[LABEL_FIELD])}`;
+  if (data['parent']) {
+    html = `${html} <span class="text-secondary">${escape(data['parent'])}</span>`;
+  }
+  if (data['count']) {
+    html = `${html} <span class="badge">${escape(data['count'])}</span>`;
+  }
+  if (data['description']) {
+    html = `${html}<br /><small class="text-secondary">${escape(data['description'])}</small>`;
   }
+  html = `${html}</div>`;
 
-  return `<div>${escape(data[LABEL_FIELD])}</div>`;
+  return html;
+}
+
+// Render the HTML for a selected item
+function renderItem(data: TomOption, escape: typeof escape_html) {
+  if (data['parent']) {
+    return `<div>${escape(data['parent'])} > ${escape(data[LABEL_FIELD])}</div>`;
+  }
+  return `<div>${escape(data[LABEL_FIELD])}<div>`;
 }
 
 // Initialize <select> elements which are populated via a REST API call
@@ -30,16 +52,13 @@ export function initDynamicSelects(): void {
       // Disable local search (search is performed on the backend)
       searchField: [],
 
-      // Reference the disabled-indicator attr on the <select> element to determine
-      // the name of the attribute which indicates whether an option should be disabled
-      disabledField: select.getAttribute('disabled-indicator') || undefined,
-
       // Load options from API immediately on focus
       preload: 'focus',
 
       // Define custom rendering functions
       render: {
         option: renderOption,
+        item: renderItem,
       },
 
       // By default, load() will be called only if query.length > 0

+ 8 - 3
netbox/project-static/src/select/static.ts

@@ -17,13 +17,18 @@ export function initStaticSelects(): void {
 
 // Initialize color selection fields
 export function initColorSelects(): void {
+  function renderColor(item: TomOption, escape: typeof escape_html) {
+    return `<div><span class="dropdown-item-indicator color-label" style="background-color: #${escape(
+      item.value,
+    )}"></span> ${escape(item.text)}</div>`;
+  }
+
   for (const select of getElements<HTMLSelectElement>('select.color-select')) {
     new TomSelect(select, {
       ...config,
       render: {
-        option: function (item: TomOption, escape: typeof escape_html) {
-          return `<div style="background-color: #${escape(item.value)}">${escape(item.text)}</div>`;
-        },
+        option: renderColor,
+        item: renderColor,
       },
     });
   }

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

@@ -63,8 +63,19 @@ class DynamicModelChoiceMixin:
         initial_params: A dictionary of child field references to use for selecting a parent field's initial value
         null_option: The string used to represent a null selection (if any)
         disabled_indicator: The name of the field which, if populated, will disable selection of the
-            choice (optional)
+            choice (DEPRECATED: pass `context={'disabled': '$fieldname'}` instead)
+        context: A mapping of <option> template variables to their API data keys (optional; see below)
         selector: Include an advanced object selection widget to assist the user in identifying the desired object
+
+    Context keys:
+        value: The name of the attribute which contains the option's value (default: 'id')
+        label: The name of the attribute used as the option's human-friendly label (default: 'display')
+        description: The name of the attribute to use as a description (default: 'description')
+        depth: The name of the attribute which indicates an object's depth within a recursive hierarchy; must be a
+            positive integer (default: '_depth')
+        disabled: The name of the attribute which, if true, signifies that the option should be disabled
+        parent: The name of the attribute which represents the object's parent object (e.g. device for an interface)
+        count: The name of the attribute which contains a numeric count of related objects
     """
     filter = django_filters.ModelChoiceFilter
     widget = widgets.APISelect
@@ -77,6 +88,7 @@ class DynamicModelChoiceMixin:
             initial_params=None,
             null_option=None,
             disabled_indicator=None,
+            context=None,
             selector=False,
             **kwargs
     ):
@@ -85,6 +97,7 @@ class DynamicModelChoiceMixin:
         self.initial_params = initial_params or {}
         self.null_option = null_option
         self.disabled_indicator = disabled_indicator
+        self.context = context or {}
         self.selector = selector
 
         super().__init__(queryset, **kwargs)
@@ -96,12 +109,17 @@ class DynamicModelChoiceMixin:
         if self.null_option is not None:
             attrs['data-null-option'] = self.null_option
 
-        # Set the disabled indicator, if any
+        # Set any custom template attributes for TomSelect
+        for var, accessor in self.context.items():
+            attrs[f'ts-{var}-field'] = accessor
+
+        # TODO: Remove in v4.1
+        # Legacy means of specifying the disabled indicator
         if self.disabled_indicator is not None:
-            attrs['disabled-indicator'] = self.disabled_indicator
+            attrs['ts-disabled-field'] = self.disabled_indicator
 
         # Attach any static query parameters
-        if (len(self.query_params) > 0):
+        if len(self.query_params) > 0:
             widget.add_query_params(self.query_params)
 
         # Include object selector?

+ 6 - 2
netbox/wireless/forms/model_forms.py

@@ -108,7 +108,9 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm):
             'kind': 'wireless',
             'device_id': '$device_a',
         },
-        disabled_indicator='_occupied',
+        context={
+            'disabled': '_occupied',
+        },
         label=_('Interface')
     )
     site_b = DynamicModelChoiceField(
@@ -148,7 +150,9 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm):
             'kind': 'wireless',
             'device_id': '$device_b',
         },
-        disabled_indicator='_occupied',
+        context={
+            'disabled': '_occupied',
+        },
         label=_('Interface')
     )
     comments = CommentField()

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