Repository: netbox-community/netbox

Issue:
Changing VLAN group scope type does not toggle selection fields correctly
### NetBox version

v3.0-beta2

### Python version

3.7

### Steps to Reproduce

1. Navigate to IPAM > VLAN Groups > Add
2. Select several different scope types and observe the selection fields below

### Expected Behavior

Initially, none of the selection fields below scope type should be present. Once a scope type has been selected, only the relevant selection fields should be displayed.

For example, if the scope type has been set to "site," only the region, site group, and site fields should be visible.

### Observed Behavior

Initially, all scope selection fields are visible instead of hidden. After selecting a scope type, the form widgets are hidden, however the parent form "rows" (with labels) are not.
Interface VLAN assignment selection not filtered by selected VLAN group
### NetBox version

v3.0-beta2

### Python version

3.7

### Steps to Reproduce

1. Create VLAN groups named A and B
2. Create a VLAN in group A named "Foo" with VLAN ID 100
3. Create a VLAN in group B named "Bar" with VLAN ID 100
4. Create or edit a device interface
5. Select "access" for the interface's 802.1Q mode
6. Select VLAN group A
7. View the available VLANs

### Expected Behavior

Only VLAN Foo (in group A) should be listed

### Observed Behavior

Both VLANs Foo and Bar are listed. This is because the API request is made with the query parameter `?vlan_group_id=` rather than `?group_id=`.


Discussion:

Resolving patch:
diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py
--- a/netbox/utilities/forms/fields.py
+++ b/netbox/utilities/forms/fields.py
@@ -375,8 +375,8 @@ class DynamicModelChoiceMixin:
     filter = django_filters.ModelChoiceFilter
     widget = widgets.APISelect
 
-    def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, fetch_trigger=None, *args,
-                 **kwargs):
+    def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, fetch_trigger=None,
+                 *args, **kwargs):
         self.query_params = query_params or {}
         self.initial_params = initial_params or {}
         self.null_option = null_option
@@ -409,8 +409,8 @@ def widget_attrs(self, widget):
             attrs['data-fetch-trigger'] = self.fetch_trigger
 
         # Attach any static query parameters
-        for key, value in self.query_params.items():
-            widget.add_query_param(key, value)
+        if (len(self.query_params) > 0):
+            widget.add_query_params(self.query_params)
 
         return attrs
 
diff --git a/netbox/utilities/forms/widgets.py b/netbox/utilities/forms/widgets.py
--- a/netbox/utilities/forms/widgets.py
+++ b/netbox/utilities/forms/widgets.py
@@ -1,4 +1,5 @@
 import json
+from typing import Dict, Sequence, List, Tuple, Union
 
 from django import forms
 from django.conf import settings
@@ -26,6 +27,11 @@
     'TimePicker',
 )
 
+JSONPrimitive = Union[str, bool, int, float, None]
+QueryParamValue = Union[JSONPrimitive, Sequence[JSONPrimitive]]
+QueryParam = Dict[str, QueryParamValue]
+ProcessedParams = Sequence[Dict[str, Sequence[JSONPrimitive]]]
+
 
 class SmallTextarea(forms.Textarea):
     """
@@ -135,29 +141,132 @@ class APISelect(SelectWithDisabled):
 
     :param api_url: API endpoint URL. Required if not set automatically by the parent field.
     """
+
+    dynamic_params: Dict[str, str]
+    static_params: Dict[str, List[str]]
+
     def __init__(self, api_url=None, full=False, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
         self.attrs['class'] = 'netbox-api-select'
+        self.dynamic_params: Dict[str, List[str]] = {}
+        self.static_params: Dict[str, List[str]] = {}
+
         if api_url:
             self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/'))  # Inject BASE_PATH
 
-    def add_query_param(self, name, value):
+    def _process_query_param(self, key: str, value: JSONPrimitive) -> None:
         """
-        Add details for an additional query param in the form of a data-* JSON-encoded list attribute.
-
-        :param name: The name of the query param
-        :param value: The value of the query param
+        Based on query param value's type and value, update instance's dynamic/static params.
         """
-        key = f'data-query-param-{name}'
-
-        values = json.loads(self.attrs.get(key, '[]'))
-        if type(value) in (list, tuple):
-            values.extend([str(v) for v in value])
+        if isinstance(value, str):
+            # Coerce `True` boolean.
+            if value.lower() == 'true':
+                value = True
+            # Coerce `False` boolean.
+            elif value.lower() == 'false':
+                value = False
+            # Query parameters cannot have a `None` (or `null` in JSON) type, convert
+            # `None` types to `'null'` so that ?key=null is used in the query URL.
+            elif value is None:
+                value = 'null'
+
+        # Check type of `value` again, since it may have changed.
+        if isinstance(value, str):
+            if value.startswith('$'):
+                # A value starting with `$` indicates a dynamic query param, where the
+                # initial value is unknown and will be updated at the JavaScript layer
+                # as the related form field's value changes.
+                field_name = value.strip('$')
+                self.dynamic_params[field_name] = key
+            else:
+                # A value _not_ starting with `$` indicates a static query param, where
+                # the value is already known and should not be changed at the JavaScript
+                # layer.
+                if key in self.static_params:
+                    current = self.static_params[key]
+                    self.static_params[key] = [*current, value]
+                else:
+                    self.static_params[key] = [value]
         else:
-            values.append(str(value))
+            # Any non-string values are passed through as static query params, since
+            # dynamic query param values have to be a string (in order to start with
+            # `$`).
+            if key in self.static_params:
+                current = self.static_params[key]
+                self.static_params[key] = [*current, value]
+            else:
+                self.static_params[key] = [value]
+
+    def _process_query_params(self, query_params: QueryParam) -> None:
+        """
+        Process an entire query_params dictionary, and handle primitive or list values.
+        """
+        for key, value in query_params.items():
+            if isinstance(value, (List, Tuple)):
+                # If value is a list/tuple, iterate through each item.
+                for item in value:
+                    self._process_query_param(key, item)
+            else:
+                self._process_query_param(key, value)
+
+    def _serialize_params(self, key: str, params: ProcessedParams) -> None:
+        """
+        Serialize dynamic or static query params to JSON and add the serialized value to
+        the widget attributes by `key`.
+        """
+        # Deserialize the current serialized value from the widget, using an empty JSON
+        # array as a fallback in the event one is not defined.
+        current = json.loads(self.attrs.get(key, '[]'))
+
+        # Combine the current values with the updated values and serialize the result as
+        # JSON. Note: the `separators` kwarg effectively removes extra whitespace from
+        # the serialized JSON string, which is ideal since these will be passed as
+        # attributes to HTML elements and parsed on the client.
+        self.attrs[key] = json.dumps([*current, *params], separators=(',', ':'))
 
-        self.attrs[key] = json.dumps(values)
+    def _add_dynamic_params(self) -> None:
+        """
+        Convert post-processed dynamic query params to data structure expected by front-
+        end, serialize the value to JSON, and add it to the widget attributes.
+        """
+        key = 'data-dynamic-params'
+        if len(self.dynamic_params) > 0:
+            try:
+                update = [{'fieldName': f, 'queryParam': q} for (f, q) in self.dynamic_params.items()]
+                self._serialize_params(key, update)
+            except IndexError as error:
+                raise RuntimeError(f"Missing required value for dynamic query param: '{self.dynamic_params}'") from error
+
+    def _add_static_params(self) -> None:
+        """
+        Convert post-processed static query params to data structure expected by front-
+        end, serialize the value to JSON, and add it to the widget attributes.
+        """
+        key = 'data-static-params'
+        if len(self.static_params) > 0:
+            try:
+                update = [{'queryParam': k, 'queryValue': v} for (k, v) in self.static_params.items()]
+                self._serialize_params(key, update)
+            except IndexError as error:
+                raise RuntimeError(f"Missing required value for static query param: '{self.static_params}'") from error
+
+    def add_query_params(self, query_params: QueryParam) -> None:
+        """
+        Proccess & add a dictionary of URL query parameters to the widget attributes.
+        """
+        # Process query parameters. This populates `self.dynamic_params` and `self.static_params`.
+        self._process_query_params(query_params)
+        # Add processed dynamic parameters to widget attributes.
+        self._add_dynamic_params()
+        # Add processed static parameters to widget attributes.
+        self._add_static_params()
+
+    def add_query_param(self, key: str, value: QueryParamValue) -> None:
+        """
+        Process & add a key/value pair of URL query parameters to the widget attributes.
+        """
+        self.add_query_params({key: value})
 
 
 class APISelectMultiple(APISelect, forms.SelectMultiple):


Test patch: