Improve APISelect query parameter handling (#7040)

* Fixes #7035: Refactor APISelect query_param logic

* Add filter_fields to extras.ObjectVar & fix default value handling

* Update ObjectVar docs to reflect new filter_fields attribute

* Revert changes from 89b7f3f

* Maintain current `query_params` API for form fields, transform data structure in widget

* Revert changes from d0208d4
This commit is contained in:
Matt Love 2021-08-30 06:43:32 -07:00 committed by GitHub
parent 1a478150d6
commit 25d1fe2c8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 512 additions and 194 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,71 +1,26 @@
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 { createToast } from '../../bs';
import { hasUrl, hasExclusions, isTrigger } from '../util';
import { DynamicParamsMap } from './dynamicParams';
import { isStaticParams } from './types';
import {
isTruthy,
hasMore,
isTruthy,
hasError,
getElement,
getApiData,
isApiError,
getElements,
createElement,
uniqueByProperty,
findFirstAdjacent,
} from '../util';
} from '../../util';
import type { Stringifiable } from 'query-string';
import type { Option } from 'slim-select/dist/data';
/**
* Map of string keys to primitive array values accepted by `query-string`. Keys are used as
* URL query parameter keys. Values correspond to query param values, enforced as an array
* for easier handling. For example, a mapping of `{ site_id: [1, 2] }` is serialized by
* `query-string` as `?site_id=1&site_id=2`. Likewise, `{ site_id: [1] }` is serialized as
* `?site_id=1`.
*/
type QueryFilter = Map<string, Stringifiable[]>;
/**
* Map of string keys to primitive values. Used to track variables within URLs from the server. For
* example, `/api/$key/thing`. `PathFilter` tracks `$key` as `{ key: '' }` in the map, and when the
* value is later known, the value is set `{ key: 'value' }`, and the URL is transformed to
* `/api/value/thing`.
*/
type PathFilter = Map<string, Stringifiable>;
/**
* Merge or replace incoming options with current options.
*/
type ApplyMethod = 'merge' | 'replace';
/**
* Trigger for which the select instance should fetch its data from the NetBox API.
*/
export type Trigger =
/**
* Load data when the select element is opened.
*/
| 'open'
/**
* Load data when the element is loaded.
*/
| 'load'
/**
* Load data when a parent element is uncollapsed.
*/
| 'collapse';
// Various one-off patterns to replace in query param keys.
const REPLACE_PATTERNS = [
// Don't query `termination_a_device=1`, but rather `device=1`.
[new RegExp(/termination_(a|b)_(.+)/g), '$2'],
// A tenant's group relationship field is `group`, but the field name is `tenant_group`.
[new RegExp(/tenant_(group)/g), '$1'],
] as [RegExp, string][];
import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types';
// Empty placeholder option.
const PLACEHOLDER = {
@ -81,7 +36,7 @@ const DISABLED_ATTRIBUTES = ['occupied'] as string[];
* Manage a single API-backed select element's state. Each API select element is likely controlled
* or dynamically updated by one or more other API select (or static select) elements' values.
*/
class APISelect {
export class APISelect {
/**
* Base `<select/>` DOM element.
*/
@ -124,24 +79,32 @@ class APISelect {
*/
private readonly slim: InstanceType<typeof SlimSelect>;
/**
* API query parameters that should be applied to API queries for this field. This will be
* updated as other dependent fields' values change. This is a mapping of:
*
* Form Field Names Form Field Values
*
* This is/might be different than the query parameters themselves, as the form field names may
* be different than the object model key names. For example, `tenant_group` would be the field
* name, but `group` would be the query parameter. Query parameters themselves are tracked in
* `queryParams`.
*/
private readonly filterParams: QueryFilter = new Map();
/**
* Post-parsed URL query parameters for API queries.
*/
private readonly queryParams: QueryFilter = new Map();
/**
* API query parameters that should be applied to API queries for this field. This will be
* updated as other dependent fields' values change. This is a mapping of:
*
* Form Field Names Object containing:
* - Query parameter key name
* - Query value
*
* 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
* query parameter keys, which are not necessarily the same as the form field names, depending on
* the model. For example, `tenant_group` would be the field name, but `group_id` would be the
* query parameter.
*/
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
* (e.g., `{{key}}`), `key` will be added to `pathValue` and the `id_key` form element will be
@ -228,20 +191,21 @@ class APISelect {
});
// Initialize API query properties.
this.getFilteredBy();
this.getStaticParams();
this.getDynamicParams();
this.getPathKeys();
for (const filter of this.filterParams.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);
}
}
// Populate dynamic path values with any form values that are already known.
for (const filter of this.pathValues.keys()) {
this.updatePathValues(filter);
}
@ -395,7 +359,8 @@ 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.filterParams.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}"]`);
@ -588,6 +553,7 @@ class APISelect {
this.updateQueryParams(target.name);
this.updatePathValues(target.name);
this.updateQueryUrl();
// Load new data.
Promise.all([this.loadData()]);
}
@ -655,27 +621,12 @@ class APISelect {
* Update an element's API URL based on the value of another element on which this element
* relies.
*
* @param id DOM ID of the other element.
* @param fieldName DOM ID of the other element.
*/
private updateQueryParams(id: string): void {
let key = id.replaceAll(/^id_/gi, '');
private updateQueryParams(fieldName: string): void {
// Find the element dependency.
const element = getElement<HTMLSelectElement>(`id_${key}`);
const element = document.querySelector<HTMLSelectElement>(`[name="${fieldName}"]`);
if (element !== null) {
// 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 (key.match(pattern)) {
key = key.replaceAll(pattern, replacement);
break;
}
}
// Force related keys to end in `_id`, if they don't already.
if (key.substring(key.length - 3) !== '_id') {
key = `${key}_id`;
}
// Initialize the element value as an array, in case there are multiple values.
let elementValue = [] as Stringifiable[];
@ -694,13 +645,38 @@ class APISelect {
if (elementValue.length > 0) {
// If the field has a value, add it to the map.
if (this.filterParams.has(id)) {
// If this instance is filtered by the neighbor element, add its value to the map.
this.queryParams.set(key, elementValue);
this.dynamicParams.updateValue(fieldName, elementValue);
// Get the updated value.
const current = this.dynamicParams.get(fieldName);
if (typeof current !== 'undefined') {
const { queryParam, queryValue } = current;
let value = [] as Stringifiable[];
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 (value.length > 0) {
this.queryParams.set(queryParam, value);
} else {
this.queryParams.delete(queryParam);
}
}
} else {
// Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
this.queryParams.delete(key);
const queryParam = this.dynamicParams.queryParam(fieldName);
if (queryParam !== null) {
this.queryParams.delete(queryParam);
}
}
}
}
@ -796,88 +772,50 @@ 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-query-param-<name of other field>`, which would look like:
* `["$<name>"]`
*
* If the attribute exists, parse out the raw value. In the above example, this would be `name`.
* 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 getFilteredBy(): void {
const pattern = new RegExp(/\[|\]|"|\$/g);
const keyPattern = new RegExp(/data-query-param-/g);
// Extract data attributes.
const keys = Object.values(this.base.attributes)
.map(v => v.name)
.filter(v => v.includes('data'));
private getDynamicParams(): void {
const serialized = this.base.getAttribute('data-dynamic-params');
try {
this.dynamicParams.addFromJson(serialized);
} catch (err) {
console.group(`Unable to determine dynamic query parameters for select field '${this.name}'`);
console.warn(err);
console.groupEnd();
}
}
/**
* Properly handle preexistence of keys, value types, and deduplication when adding a filter to
* `filterParams`.
* Determine if this instance's options should be filtered by static values passed from the
* server.
*
* _Note: This is an unnamed function so that it can access `this`._
* 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`.
*/
const addFilter = (key: string, value: Stringifiable): void => {
const current = this.filterParams.get(key);
private getStaticParams(): void {
const serialized = this.base.getAttribute('data-static-params');
if (typeof current !== 'undefined') {
// This instance is already filtered by `key`, so we should add the new `value`.
// Merge and deduplicate the current filter parameter values with the incoming value.
const next = Array.from(
new Set<Stringifiable>([...(current as Stringifiable[]), value]),
);
this.filterParams.set(key, next);
} else {
// This instance is not already filtered by `key`, so we should add a new mapping.
if (value === '') {
// Don't add placeholder values.
this.filterParams.set(key, []);
} else {
// If the value is not a placeholder, add it.
this.filterParams.set(key, [value]);
}
}
};
for (const key of keys) {
if (key.match(keyPattern) && key !== 'data-query-param-exclude') {
const value = this.base.getAttribute(key);
if (value !== null) {
try {
const parsed = JSON.parse(value) as string | string[];
if (Array.isArray(parsed)) {
// Query param contains multiple values.
for (const item of parsed) {
if (item.match(/^\$.+$/g)) {
// Value is an unfulfilled variable.
addFilter(item.replaceAll(pattern, ''), '');
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 {
// Value has been fulfilled and is a real value to query.
addFilter(key.replaceAll(keyPattern, ''), item);
this.staticParams.set(queryParam, [queryValue]);
}
}
} else {
if (parsed.match(/^\$.+$/g)) {
// Value is an unfulfilled variable.
addFilter(parsed.replaceAll(pattern, ''), '');
} else {
// Value has been fulfilled and is a real value to query.
addFilter(key.replaceAll(keyPattern, ''), parsed);
}
}
} catch (err) {
console.group(`Unable to determine static query parameters for select field '${this.name}'`);
console.warn(err);
if (value.match(/^\$.+$/g)) {
// Value is an unfulfilled variable.
addFilter(value.replaceAll(pattern, ''), '');
} else {
// Value has been fulfilled and is a real value to query.
addFilter(key.replaceAll(keyPattern, ''), value);
}
}
}
}
console.groupEnd();
}
}
@ -990,9 +928,3 @@ class APISelect {
}
}
}
export function initApiSelect(): void {
for (const select of getElements<HTMLSelectElement>('.netbox-api-select')) {
new APISelect(select);
}
}

View File

@ -0,0 +1,76 @@
import { isTruthy } from '../../util';
import { isDataDynamicParams } from './types';
import type { QueryParam } from './types';
/**
* Extension of built-in `Map` to add convenience functions.
*/
export class DynamicParamsMap extends Map<string, QueryParam> {
/**
* Get the query parameter key based on field name.
*
* @param fieldName Related field name.
* @returns `queryParam` key.
*/
public queryParam(fieldName: string): Nullable<QueryParam['queryParam']> {
const value = this.get(fieldName);
if (typeof value !== 'undefined') {
return value.queryParam;
}
return null;
}
/**
* Get the query parameter value based on field name.
*
* @param fieldName Related field name.
* @returns `queryValue` value, or an empty array if there is no corresponding Map entry.
*/
public queryValue(fieldName: string): QueryParam['queryValue'] {
const value = this.get(fieldName);
if (typeof value !== 'undefined') {
return value.queryValue;
}
return [];
}
/**
* Update the value of a field when the value changes.
*
* @param fieldName Related field name.
* @param queryValue New value.
* @returns `true` if the update was successful, `false` if there was no corresponding Map entry.
*/
public updateValue(fieldName: string, queryValue: QueryParam['queryValue']): boolean {
const current = this.get(fieldName);
if (isTruthy(current)) {
const { queryParam } = current;
this.set(fieldName, { queryParam, queryValue });
return true;
}
return false;
}
/**
* Populate the underlying map based on the JSON passed in the `data-dynamic-params` 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 (isDataDynamicParams(deserialized)) {
for (const { queryParam, fieldName } of deserialized) {
// Populate the underlying map with the initial data.
this.set(fieldName, { queryParam, queryValue: [] });
}
} else {
throw new Error(
`Data from 'data-dynamic-params' attribute is improperly formatted: '${json}'`,
);
}
}
}
}

View File

@ -0,0 +1,10 @@
import { getElements } from '../../util';
import { APISelect } from './apiSelect';
export function initApiSelect(): void {
for (const select of getElements<HTMLSelectElement>('.netbox-api-select')) {
new APISelect(select);
}
}
export type { Trigger } from './types';

View File

@ -0,0 +1,189 @@
import type { Stringifiable } from 'query-string';
/**
* Map of string keys to primitive array values accepted by `query-string`. Keys are used as
* URL query parameter keys. Values correspond to query param values, enforced as an array
* for easier handling. For example, a mapping of `{ site_id: [1, 2] }` is serialized by
* `query-string` as `?site_id=1&site_id=2`. Likewise, `{ site_id: [1] }` is serialized as
* `?site_id=1`.
*/
export type QueryFilter = Map<string, Stringifiable[]>;
/**
* Tracked data for a related field. This is the value of `APISelect.filterFields`.
*/
export type FilterFieldValue = {
/**
* Key to use in the query parameter itself.
*/
queryParam: string;
/**
* Value to use in the query parameter for the related field.
*/
queryValue: Stringifiable[];
/**
* @see `DataFilterFields.includeNull`
*/
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.
*/
export type DataFilterFields = {
/**
* Related field form name (`[name="<fieldName>"]`)
*
* @example tenant_group
*/
fieldName: string;
/**
* Key to use in the query parameter itself.
*
* @example group_id
*/
queryParam: string;
/**
* Optional default value. If set, value will be added to the query parameters prior to the
* initial API call and will be maintained until the field `fieldName` references (if one exists)
* is updated with a new value.
*
* @example 1
*/
defaultValue: Nullable<Stringifiable | Stringifiable[]>;
/**
* Include `null` on queries for the related field. For example, if `true`, `?<fieldName>=null`
* will be added to all API queries for this field.
*/
includeNull: boolean;
};
/**
* Map of string keys to primitive values. Used to track variables within URLs from the server. For
* example, `/api/$key/thing`. `PathFilter` tracks `$key` as `{ key: '' }` in the map, and when the
* value is later known, the value is set `{ key: 'value' }`, and the URL is transformed to
* `/api/value/thing`.
*/
export type PathFilter = Map<string, Stringifiable>;
/**
* Merge or replace incoming options with current options.
*/
export type ApplyMethod = 'merge' | 'replace';
/**
* Trigger for which the select instance should fetch its data from the NetBox API.
*/
export type Trigger =
/**
* Load data when the select element is opened.
*/
| 'open'
/**
* Load data when the element is loaded.
*/
| 'load'
/**
* Load data when a parent element is uncollapsed.
*/
| 'collapse';
/**
* Strict Type Guard to determine if a deserialized value from the `data-filter-fields` attribute
* is of type `DataFilterFields`.
*
* @param value Deserialized value from `data-filter-fields` attribute.
*/
export function isDataFilterFields(value: unknown): value is DataFilterFields[] {
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 DataFilterFields).fieldName === 'string' &&
typeof (item as DataFilterFields).queryParam === 'string'
);
}
}
}
}
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;
}

View File

@ -39,6 +39,8 @@ export function isTruthy<V extends unknown>(value: V): value is NonNullable<V> {
return true;
} else if (typeof value === 'boolean') {
return true;
} else if (typeof value === 'object' && value !== null) {
return true;
}
return false;
}

View File

@ -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 @@ class DynamicModelChoiceMixin:
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

View File

@ -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 @@ __all__ = (
'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}'
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'
values = json.loads(self.attrs.get(key, '[]'))
if type(value) in (list, tuple):
values.extend([str(v) for v in value])
# 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:
values.append(str(value))
# 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]
self.attrs[key] = json.dumps(values)
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=(',', ':'))
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):