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

This commit is contained in:
thatmattlove 2021-08-28 09:42:28 -07:00
parent 809de8683b
commit 658ac89af0
6 changed files with 279 additions and 135 deletions

Binary file not shown.

Binary file not shown.

View File

@ -1,13 +1,14 @@
import queryString from 'query-string';
import debounce from 'just-debounce-it';
import { readableColor } from 'color2k'; import { readableColor } from 'color2k';
import debounce from 'just-debounce-it';
import queryString from 'query-string';
import SlimSelect from 'slim-select'; import SlimSelect from 'slim-select';
import { createToast } from '../../bs'; import { createToast } from '../../bs';
import { hasUrl, hasExclusions, isTrigger } from '../util'; import { hasUrl, hasExclusions, isTrigger } from '../util';
import { FilterFieldMap } from './filterFields'; import { DynamicParamsMap } from './dynamicParams';
import { isStaticParams } from './types';
import { import {
isTruthy,
hasMore, hasMore,
isTruthy,
hasError, hasError,
getElement, getElement,
getApiData, getApiData,
@ -90,8 +91,6 @@ export class APISelect {
* Form Field Names Object containing: * Form Field Names Object containing:
* - Query parameter key name * - Query parameter key name
* - Query value * - 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 * 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 * 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 * the model. For example, `tenant_group` would be the field name, but `group_id` would be the
* query parameter. * 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 * 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. // Initialize API query properties.
// this.getFilteredBy(); this.getStaticParams();
this.getFilterFields(); this.getDynamicParams();
this.getPathKeys(); this.getPathKeys();
// for (const filter of this.filterParams.keys()) { // Populate static query parameters.
// this.updateQueryParams(filter); for (const [key, value] of this.staticParams.entries()) {
// } this.queryParams.set(key, value);
for (const filter of this.filterFields.keys()) { }
// Populate dynamic query parameters with any form values that are already known.
for (const filter of this.dynamicParams.keys()) {
this.updateQueryParams(filter); this.updateQueryParams(filter);
} }
// Add any already-resolved key/value pairs to the API query parameters. // Populate dynamic path values with any form values that are already known.
// 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);
}
}
for (const filter of this.pathValues.keys()) { for (const filter of this.pathValues.keys()) {
this.updatePathValues(filter); 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 // Create a unique iterator of all possible form fields which, when changed, should cause this
// element to update its API query. // 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.filterFields.keys(), ...this.pathValues.keys()]); const dependencies = new Set([...this.dynamicParams.keys(), ...this.pathValues.keys()]);
for (const dep of dependencies) { for (const dep of dependencies) {
const filterElement = document.querySelector(`[name="${dep}"]`); const filterElement = document.querySelector(`[name="${dep}"]`);
@ -559,11 +554,6 @@ export class APISelect {
this.updatePathValues(target.name); this.updatePathValues(target.name);
this.updateQueryUrl(); this.updateQueryUrl();
console.group(this.name, this.queryUrl);
console.log(this.filterFields);
console.log(this.queryParams);
console.groupEnd();
// Load new data. // Load new data.
Promise.all([this.loadData()]); Promise.all([this.loadData()]);
} }
@ -655,18 +645,27 @@ export class APISelect {
if (elementValue.length > 0) { if (elementValue.length > 0) {
// If the field has a value, add it to the map. // If the field has a value, add it to the map.
this.filterFields.updateValue(fieldName, elementValue); this.dynamicParams.updateValue(fieldName, elementValue);
// Get the updated value.
const current = this.filterFields.get(fieldName); const current = this.dynamicParams.get(fieldName);
if (typeof current !== 'undefined') { if (typeof current !== 'undefined') {
const { queryParam, queryValue, includeNull } = current; const { queryParam, queryValue } = current;
let value = [] as Stringifiable[]; 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) { if (value.length > 0) {
value = [...value, ...queryValue];
this.queryParams.set(queryParam, value); this.queryParams.set(queryParam, value);
} else { } else {
this.queryParams.delete(queryParam); this.queryParams.delete(queryParam);
@ -674,7 +673,7 @@ export class APISelect {
} }
} else { } else {
// Otherwise, delete it (we don't want to send an empty query like `?site_id=`) // 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) { if (queryParam !== null) {
this.queryParams.delete(queryParam); 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. * objects containing information about how to handle the related field.
*/ */
private getFilterFields(): void { private getDynamicParams(): void {
const serialized = this.base.getAttribute('data-filter-fields'); const serialized = this.base.getAttribute('data-dynamic-params');
try { try {
this.filterFields.addFromJson(serialized); this.dynamicParams.addFromJson(serialized);
} catch (err) { } 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.warn(err);
console.groupEnd(); console.groupEnd();
} }

View File

@ -1,20 +1,19 @@
import { isTruthy } from '../../util'; import { isTruthy } from '../../util';
import { isDataFilterFields } from './types'; import { isDataDynamicParams } from './types';
import type { Stringifiable } from 'query-string'; import type { QueryParam } from './types';
import type { FilterFieldValue } from './types';
/** /**
* Extension of built-in `Map` to add convenience functions. * Extension of built-in `Map` to add convenience functions.
*/ */
export class FilterFieldMap extends Map<string, FilterFieldValue> { export class DynamicParamsMap extends Map<string, QueryParam> {
/** /**
* Get the query parameter key based on field name. * Get the query parameter key based on field name.
* *
* @param fieldName Related field name. * @param fieldName Related field name.
* @returns `queryParam` key. * @returns `queryParam` key.
*/ */
public queryParam(fieldName: string): Nullable<FilterFieldValue['queryParam']> { public queryParam(fieldName: string): Nullable<QueryParam['queryParam']> {
const value = this.get(fieldName); const value = this.get(fieldName);
if (typeof value !== 'undefined') { if (typeof value !== 'undefined') {
return value.queryParam; return value.queryParam;
@ -28,7 +27,7 @@ export class FilterFieldMap extends Map<string, FilterFieldValue> {
* @param fieldName Related field name. * @param fieldName Related field name.
* @returns `queryValue` value, or an empty array if there is no corresponding Map entry. * @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); const value = this.get(fieldName);
if (typeof value !== 'undefined') { if (typeof value !== 'undefined') {
return value.queryValue; return value.queryValue;
@ -43,43 +42,33 @@ export class FilterFieldMap extends Map<string, FilterFieldValue> {
* @param queryValue New value. * @param queryValue New value.
* @returns `true` if the update was successful, `false` if there was no corresponding Map entry. * @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); const current = this.get(fieldName);
if (isTruthy(current)) { if (isTruthy(current)) {
const { queryParam, includeNull } = current; const { queryParam } = current;
this.set(fieldName, { queryParam, queryValue, includeNull }); this.set(fieldName, { queryParam, queryValue });
return true; return true;
} }
return false; 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 { public addFromJson(json: string | null | undefined): void {
if (isTruthy(json)) { if (isTruthy(json)) {
const deserialized = JSON.parse(json); const deserialized = JSON.parse(json);
// Ensure the value is the data structure we expect. // Ensure the value is the data structure we expect.
if (isDataFilterFields(deserialized)) { if (isDataDynamicParams(deserialized)) {
for (const { queryParam, fieldName, defaultValue, includeNull } of deserialized) { for (const { queryParam, fieldName } 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];
}
}
// Populate the underlying map with the initial data. // Populate the underlying map with the initial data.
this.set(fieldName, { queryParam, queryValue, includeNull }); this.set(fieldName, { queryParam, queryValue: [] });
} }
} else { } else {
throw new Error( throw new Error(
`Data from 'data-filter-fields' attribute is improperly formatted: '${json}'`, `Data from 'data-dynamic-params' attribute is improperly formatted: '${json}'`,
); );
} }
} }

View File

@ -27,6 +27,40 @@ export type FilterFieldValue = {
includeNull: boolean; 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. * 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; 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

@ -1,5 +1,5 @@
import json import json
from typing import Dict, Sequence, Union from typing import Dict, Sequence, List, Tuple, Union
from django import forms from django import forms
from django.conf import settings from django.conf import settings
@ -28,6 +28,9 @@ __all__ = (
) )
JSONPrimitive = Union[str, bool, int, float, None] 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): 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. :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): def __init__(self, api_url=None, full=False, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.attrs['class'] = 'netbox-api-select' self.attrs['class'] = 'netbox-api-select'
self.dynamic_params: Dict[str, List[str]] = {}
self.static_params: Dict[str, List[str]] = {}
if api_url: if api_url:
self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH 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 Process an entire query_params dictionary, and handle primitive or list values.
be added to this APISelect's URL query parameters. """
for key, value in query_params.items():
:Example: if isinstance(value, (List, Tuple)):
# If value is a list/tuple, iterate through each item.
```python for item in value:
{ self._process_query_param(key, item)
'field_name': 'tenant_group', else:
'accessor': 'tenant', self._process_query_param(key, value)
'default_value': 1,
'include_null': False, 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`.
: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`, `?<fieldName>=null` will
be added to all API queries for this field.
""" """
key = 'data-filter-fields'
# Deserialize the current serialized value from the widget, using an empty JSON # Deserialize the current serialized value from the widget, using an empty JSON
# array as a fallback in the event one is not defined. # array as a fallback in the event one is not defined.
current = json.loads(self.attrs.get(key, '[]')) 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 # Combine the current values with the updated values and serialize the result as
# JSON. Note: the `separators` kwarg effectively removes extra whitespace from # JSON. Note: the `separators` kwarg effectively removes extra whitespace from
# the serialized JSON string, which is ideal since these will be passed as # the serialized JSON string, which is ideal since these will be passed as
# attributes to HTML elements and parsed on the client. # 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): class APISelectMultiple(APISelect, forms.SelectMultiple):