diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 091be6775..e1a6dcbe5 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index ee27fd56b..15c4dcf86 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index b1c381fd2..51648fc48 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -1,13 +1,14 @@ -import queryString from 'query-string'; -import debounce from 'just-debounce-it'; import { readableColor } from 'color2k'; +import debounce from 'just-debounce-it'; +import queryString from 'query-string'; import SlimSelect from 'slim-select'; import { createToast } from '../../bs'; import { hasUrl, hasExclusions, isTrigger } from '../util'; -import { FilterFieldMap } from './filterFields'; +import { DynamicParamsMap } from './dynamicParams'; +import { isStaticParams } from './types'; import { - isTruthy, hasMore, + isTruthy, hasError, getElement, getApiData, @@ -90,8 +91,6 @@ export class APISelect { * Form Field Names → Object containing: * - Query parameter key name * - Query value - * - Other options such as a default value, and the option to include - * null values. * * This is different from `queryParams` in that it tracks all _possible_ related fields and their * values, even if they are empty. Further, the keys in `queryParams` correspond to the actual @@ -99,7 +98,12 @@ export class APISelect { * the model. For example, `tenant_group` would be the field name, but `group_id` would be the * query parameter. */ - private readonly filterFields: FilterFieldMap = new FilterFieldMap(); + private readonly dynamicParams: DynamicParamsMap = new DynamicParamsMap(); + + /** + * API query parameters that are already known by the server and should not change. + */ + private readonly staticParams: QueryFilter = new Map(); /** * Mapping of URL template key/value pairs. If this element's URL contains Django template tags @@ -187,30 +191,21 @@ export class APISelect { }); // Initialize API query properties. - // this.getFilteredBy(); - this.getFilterFields(); + this.getStaticParams(); + this.getDynamicParams(); this.getPathKeys(); - // for (const filter of this.filterParams.keys()) { - // this.updateQueryParams(filter); - // } - for (const filter of this.filterFields.keys()) { + // Populate static query parameters. + for (const [key, value] of this.staticParams.entries()) { + this.queryParams.set(key, value); + } + + // Populate dynamic query parameters with any form values that are already known. + for (const filter of this.dynamicParams.keys()) { this.updateQueryParams(filter); } - // Add any already-resolved key/value pairs to the API query parameters. - // for (const [key, value] of this.filterParams.entries()) { - // if (isTruthy(value)) { - // this.queryParams.set(key, value); - // } - // } - for (const value of this.filterFields.values()) { - const { queryParam, queryValue } = value; - if (isTruthy(queryValue)) { - this.queryParams.set(queryParam, queryValue); - } - } - + // Populate dynamic path values with any form values that are already known. for (const filter of this.pathValues.keys()) { this.updatePathValues(filter); } @@ -365,7 +360,7 @@ export class APISelect { // Create a unique iterator of all possible form fields which, when changed, should cause this // element to update its API query. // const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]); - const dependencies = new Set([...this.filterFields.keys(), ...this.pathValues.keys()]); + const dependencies = new Set([...this.dynamicParams.keys(), ...this.pathValues.keys()]); for (const dep of dependencies) { const filterElement = document.querySelector(`[name="${dep}"]`); @@ -559,11 +554,6 @@ export class APISelect { this.updatePathValues(target.name); this.updateQueryUrl(); - console.group(this.name, this.queryUrl); - console.log(this.filterFields); - console.log(this.queryParams); - console.groupEnd(); - // Load new data. Promise.all([this.loadData()]); } @@ -655,18 +645,27 @@ export class APISelect { if (elementValue.length > 0) { // If the field has a value, add it to the map. - this.filterFields.updateValue(fieldName, elementValue); - - const current = this.filterFields.get(fieldName); + this.dynamicParams.updateValue(fieldName, elementValue); + // Get the updated value. + const current = this.dynamicParams.get(fieldName); if (typeof current !== 'undefined') { - const { queryParam, queryValue, includeNull } = current; + const { queryParam, queryValue } = current; let value = [] as Stringifiable[]; - if (includeNull) { - value = [...value, null]; + + if (this.staticParams.has(queryParam)) { + // If the field is defined in `staticParams`, we should merge the dynamic value with + // the static value. + const staticValue = this.staticParams.get(queryParam); + if (typeof staticValue !== 'undefined') { + value = [...staticValue, ...queryValue]; + } + } else { + // If the field is _not_ defined in `staticParams`, we should replace the current value + // with the new dynamic value. + value = queryValue; } - if (queryValue.length > 0) { - value = [...value, ...queryValue]; + if (value.length > 0) { this.queryParams.set(queryParam, value); } else { this.queryParams.delete(queryParam); @@ -674,7 +673,7 @@ export class APISelect { } } else { // Otherwise, delete it (we don't want to send an empty query like `?site_id=`) - const queryParam = this.filterFields.queryParam(fieldName); + const queryParam = this.dynamicParams.queryParam(fieldName); if (queryParam !== null) { this.queryParams.delete(queryParam); } @@ -773,17 +772,48 @@ export class APISelect { } /** - * Determine if a select element should be filtered by the value of another select element. + * Determine if a this instances' options should be filtered by the value of another select + * element. * - * Looks for the DOM attribute `data-filter-fields`, the value of which is a JSON array of + * Looks for the DOM attribute `data-dynamic-params`, the value of which is a JSON array of * objects containing information about how to handle the related field. */ - private getFilterFields(): void { - const serialized = this.base.getAttribute('data-filter-fields'); + private getDynamicParams(): void { + const serialized = this.base.getAttribute('data-dynamic-params'); try { - this.filterFields.addFromJson(serialized); + this.dynamicParams.addFromJson(serialized); } catch (err) { - console.group(`Unable to determine filter fields for select field '${this.name}'`); + console.group(`Unable to determine dynamic query parameters for select field '${this.name}'`); + console.warn(err); + console.groupEnd(); + } + } + + /** + * Determine if this instance's options should be filtered by static values passed from the + * server. + * + * Looks for the DOM attribute `data-static-params`, the value of which is a JSON array of + * objects containing key/value pairs to add to `this.staticParams`. + */ + private getStaticParams(): void { + const serialized = this.base.getAttribute('data-static-params'); + + try { + if (isTruthy(serialized)) { + const deserialized = JSON.parse(serialized); + if (isStaticParams(deserialized)) { + for (const { queryParam, queryValue } of deserialized) { + if (Array.isArray(queryValue)) { + this.staticParams.set(queryParam, queryValue); + } else { + this.staticParams.set(queryParam, [queryValue]); + } + } + } + } + } catch (err) { + console.group(`Unable to determine static query parameters for select field '${this.name}'`); console.warn(err); console.groupEnd(); } diff --git a/netbox/project-static/src/select/api/filterFields.ts b/netbox/project-static/src/select/api/dynamicParams.ts similarity index 51% rename from netbox/project-static/src/select/api/filterFields.ts rename to netbox/project-static/src/select/api/dynamicParams.ts index 75d79d849..c31c1962b 100644 --- a/netbox/project-static/src/select/api/filterFields.ts +++ b/netbox/project-static/src/select/api/dynamicParams.ts @@ -1,20 +1,19 @@ import { isTruthy } from '../../util'; -import { isDataFilterFields } from './types'; +import { isDataDynamicParams } from './types'; -import type { Stringifiable } from 'query-string'; -import type { FilterFieldValue } from './types'; +import type { QueryParam } from './types'; /** * Extension of built-in `Map` to add convenience functions. */ -export class FilterFieldMap extends Map { +export class DynamicParamsMap extends Map { /** * Get the query parameter key based on field name. * * @param fieldName Related field name. * @returns `queryParam` key. */ - public queryParam(fieldName: string): Nullable { + public queryParam(fieldName: string): Nullable { const value = this.get(fieldName); if (typeof value !== 'undefined') { return value.queryParam; @@ -28,7 +27,7 @@ export class FilterFieldMap extends Map { * @param fieldName Related field name. * @returns `queryValue` value, or an empty array if there is no corresponding Map entry. */ - public queryValue(fieldName: string): FilterFieldValue['queryValue'] { + public queryValue(fieldName: string): QueryParam['queryValue'] { const value = this.get(fieldName); if (typeof value !== 'undefined') { return value.queryValue; @@ -43,43 +42,33 @@ export class FilterFieldMap extends Map { * @param queryValue New value. * @returns `true` if the update was successful, `false` if there was no corresponding Map entry. */ - public updateValue(fieldName: string, queryValue: FilterFieldValue['queryValue']): boolean { + public updateValue(fieldName: string, queryValue: QueryParam['queryValue']): boolean { const current = this.get(fieldName); if (isTruthy(current)) { - const { queryParam, includeNull } = current; - this.set(fieldName, { queryParam, queryValue, includeNull }); + const { queryParam } = current; + this.set(fieldName, { queryParam, queryValue }); return true; } return false; } /** - * Populate the underlying map based on the JSON passed in the `data-filter-fields` attribute. + * Populate the underlying map based on the JSON passed in the `data-dynamic-params` attribute. * - * @param json Raw JSON string from `data-filter-fields` attribute. + * @param json Raw JSON string from `data-dynamic-params` attribute. */ public addFromJson(json: string | null | undefined): void { if (isTruthy(json)) { const deserialized = JSON.parse(json); // Ensure the value is the data structure we expect. - if (isDataFilterFields(deserialized)) { - for (const { queryParam, fieldName, defaultValue, includeNull } of deserialized) { - let queryValue = [] as Stringifiable[]; - if (isTruthy(defaultValue)) { - // Add the default value, if it exists. - if (Array.isArray(defaultValue)) { - // If the default value is an array, add all elements to the value. - queryValue = [...queryValue, ...defaultValue]; - } else { - queryValue = [defaultValue]; - } - } + if (isDataDynamicParams(deserialized)) { + for (const { queryParam, fieldName } of deserialized) { // Populate the underlying map with the initial data. - this.set(fieldName, { queryParam, queryValue, includeNull }); + this.set(fieldName, { queryParam, queryValue: [] }); } } else { throw new Error( - `Data from 'data-filter-fields' attribute is improperly formatted: '${json}'`, + `Data from 'data-dynamic-params' attribute is improperly formatted: '${json}'`, ); } } diff --git a/netbox/project-static/src/select/api/types.ts b/netbox/project-static/src/select/api/types.ts index 304054336..a6ab12794 100644 --- a/netbox/project-static/src/select/api/types.ts +++ b/netbox/project-static/src/select/api/types.ts @@ -27,6 +27,40 @@ export type FilterFieldValue = { includeNull: boolean; }; +/** + * JSON data structure from `data-dynamic-params` attribute. + */ +export type DataDynamicParam = { + /** + * Name of form field to track. + * + * @example [name="tenant_group"] + */ + fieldName: string; + /** + * Query param key. + * + * @example group_id + */ + queryParam: string; +}; + +/** + * `queryParams` Map value. + */ +export type QueryParam = { + queryParam: string; + queryValue: Stringifiable[]; +}; + +/** + * JSON data structure from `data-static-params` attribute. + */ +export type DataStaticParam = { + queryParam: string; + queryValue: Stringifiable | Stringifiable[]; +}; + /** * JSON data passed from Django on the `data-filter-fields` attribute. */ @@ -109,3 +143,47 @@ export function isDataFilterFields(value: unknown): value is DataFilterFields[] } return false; } + +/** + * Strict Type Guard to determine if a deserialized value from the `data-dynamic-params` attribute + * is of type `DataDynamicParam[]`. + * + * @param value Deserialized value from `data-dynamic-params` attribute. + */ +export function isDataDynamicParams(value: unknown): value is DataDynamicParam[] { + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === 'object' && item !== null) { + if ('fieldName' in item && 'queryParam' in item) { + return ( + typeof (item as DataDynamicParam).fieldName === 'string' && + typeof (item as DataDynamicParam).queryParam === 'string' + ); + } + } + } + } + return false; +} + +/** + * Strict Type Guard to determine if a deserialized value from the `data-static-params` attribute + * is of type `DataStaticParam[]`. + * + * @param value Deserialized value from `data-static-params` attribute. + */ +export function isStaticParams(value: unknown): value is DataStaticParam[] { + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === 'object' && item !== null) { + if ('queryParam' in item && 'queryValue' in item) { + return ( + typeof (item as DataStaticParam).queryParam === 'string' && + typeof (item as DataStaticParam).queryValue !== 'undefined' + ); + } + } + } + } + return false; +} diff --git a/netbox/utilities/forms/widgets.py b/netbox/utilities/forms/widgets.py index 6de51a29f..e22311b71 100644 --- a/netbox/utilities/forms/widgets.py +++ b/netbox/utilities/forms/widgets.py @@ -1,5 +1,5 @@ import json -from typing import Dict, Sequence, Union +from typing import Dict, Sequence, List, Tuple, Union from django import forms from django.conf import settings @@ -28,6 +28,9 @@ __all__ = ( ) 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): @@ -138,88 +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, key: str, value: JSONPrimitive) -> None: + def _process_query_param(self, key: str, value: JSONPrimitive) -> None: """ - Add a query parameter with a static value to the API request. + Based on query param value's type and value, update instance's dynamic/static params. """ - self.add_filter_fields({'accessor': key, 'field_name': key, 'default_value': 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' - def add_filter_fields(self, filter_fields: Union[Dict[str, JSONPrimitive], Sequence[Dict[str, JSONPrimitive]]]) -> None: + # 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: + # 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: """ - Add details about another form field, the value for which should - be added to this APISelect's URL query parameters. - - :Example: - - ```python - { - 'field_name': 'tenant_group', - 'accessor': 'tenant', - 'default_value': 1, - 'include_null': False, - } - ``` - - :param filter_fields: Dict or list of dicts with the following properties: - - - accessor: The related field's property name. For example, on the - `Tenant`model, a related model might be `TenantGroup`. In - this case, `accessor` would be `group_id`. - - - field_name: The related field's form name. In the above `Tenant` - example, `field_name` would be `tenant_group`. - - - default_value: (Optional) Set a default initial value, which can be - overridden if the field changes. - - - include_null: (Optional) Include `null` on queries for the related - field. For example, if `True`, `?=null` will - be added to all API queries for this field. - + 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`. """ - key = 'data-filter-fields' # 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, '[]')) - # Create a new list of filter fields using camelCse to align with front-end code standards - # (this value will be read and used heavily at the JavaScript layer). - update: Sequence[Dict[str, str]] = [] - try: - if isinstance(filter_fields, Sequence): - update = [ - { - 'fieldName': field['field_name'], - 'queryParam': field['accessor'], - 'defaultValue': field.get('default_value'), - 'includeNull': field.get('include_null', False), - } for field in filter_fields - ] - elif isinstance(filter_fields, Dict): - update = [ - { - 'fieldName': filter_fields['field_name'], - 'queryParam': filter_fields['accessor'], - 'defaultValue': filter_fields.get('default_value'), - 'includeNull': filter_fields.get('include_null', False), - } - ] - - except KeyError as error: - raise KeyError(f"Missing required property '{error.args[0]}' on APISelect.filter_fields") from error - # 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, *update], separators=(',', ':')) + self.attrs[key] = json.dumps([*current, *params], separators=(',', ':')) + + 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):