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 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();
}

View File

@ -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<string, FilterFieldValue> {
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<FilterFieldValue['queryParam']> {
public queryParam(fieldName: string): Nullable<QueryParam['queryParam']> {
const value = this.get(fieldName);
if (typeof value !== 'undefined') {
return value.queryParam;
@ -28,7 +27,7 @@ export class FilterFieldMap extends Map<string, FilterFieldValue> {
* @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<string, FilterFieldValue> {
* @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}'`,
);
}
}

View File

@ -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;
}

View File

@ -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`, `?<fieldName>=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):