Fixes #7035: Refactor APISelect query_param logic

This commit is contained in:
thatmattlove 2021-08-26 00:14:26 -07:00
parent 0d61dcb1bc
commit 89b7f3f19d
22 changed files with 825 additions and 636 deletions

View File

@ -131,10 +131,10 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id',
'site_group_id': '$site_group_id',
},
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region_id'},
{'accessor': 'site_group_id', 'field_name': 'site_group_id'}
],
label=_('Site'),
fetch_trigger='open'
)
@ -405,9 +405,9 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
provider_network_id = DynamicModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
required=False,
query_params={
'provider_id': '$provider_id'
},
filter_fields=[
{'accessor': 'provider_id', 'field_name': 'provider_id'}
],
label=_('Provider network'),
fetch_trigger='open'
)
@ -431,10 +431,10 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id',
'site_group_id': '$site_group_id',
},
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region_id'},
{'accessor': 'site_group_id', 'field_name': 'site_group_id'},
],
label=_('Site'),
fetch_trigger='open'
)
@ -467,10 +467,10 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
query_params={
'region_id': '$region',
'group_id': '$site_group',
},
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region'},
{'accessor': 'site_group_id', 'field_name': 'site_group'},
],
required=False
)
provider_network = DynamicModelChoiceField(

File diff suppressed because it is too large Load Diff

View File

@ -437,19 +437,19 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=Site.objects.all(),
required=False,
null_option='None',
query_params={
'region_id': '$region',
'group_id': '$site_group',
}
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region'},
{'accessor': 'group_id', 'field_name': 'site_group'},
]
)
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
label='VLAN group',
null_option='None',
query_params={
'site_id': '$site'
},
filter_fields=[
{'accessor': 'site_id', 'field_name': 'site'},
],
initial_params={
'vlans': '$vlan'
}
@ -458,10 +458,10 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=VLAN.objects.all(),
required=False,
label='VLAN',
query_params={
'site_id': '$site',
'group_id': '$vlan_group',
}
filter_fields=[
{'accessor': 'site_id', 'field_name': 'site'},
{'accessor': 'group_id', 'field_name': 'vlan_group'},
]
)
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
@ -573,10 +573,10 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region',
'group_id': '$site_group',
}
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region'},
{'accessor': 'group_id', 'field_name': 'site_group'},
],
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
@ -695,9 +695,9 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
queryset=Site.objects.all(),
required=False,
null_option='None',
query_params={
'region_id': '$region_id'
},
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region_id'},
],
label=_('Site'),
fetch_trigger='open'
)
@ -883,9 +883,9 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
interface = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
query_params={
'device_id': '$device'
}
filter_fields=[
{'accessor': 'device_id', 'field_name': 'device'},
],
)
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
@ -898,9 +898,9 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=VMInterface.objects.all(),
required=False,
label='Interface',
query_params={
'virtual_machine_id': '$virtual_machine'
}
filter_fields=[
{'accessor': 'virtual_machine_id', 'field_name': 'virtual_machine'},
]
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
@ -927,28 +927,28 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=Site.objects.all(),
required=False,
label='Site',
query_params={
'region_id': '$nat_region',
'group_id': '$nat_site_group',
}
filter_fields=[
{'accessor': 'region_id', 'field_name': 'nat_region'},
{'accessor': 'group_id', 'field_name': 'nat_site_group'},
],
)
nat_rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
label='Rack',
null_option='None',
query_params={
'site_id': '$site'
}
filter_fields=[
{'accessor': 'site_id', 'field_name': 'site'},
],
)
nat_device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
label='Device',
query_params={
'site_id': '$site',
'rack_id': '$nat_rack',
}
filter_fields=[
{'accessor': 'site_id', 'field_name': 'site'},
{'accessor': 'rack_id', 'field_name': 'nat_rack'},
]
)
nat_cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
@ -959,9 +959,9 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=VirtualMachine.objects.all(),
required=False,
label='Virtual Machine',
query_params={
'cluster_id': '$nat_cluster',
}
filter_fields=[
{'accessor': 'cluster_id', 'field_name': 'nat_cluster'},
]
)
nat_vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
@ -972,11 +972,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=IPAddress.objects.all(),
required=False,
label='IP Address',
query_params={
'device_id': '$nat_device',
'virtual_machine_id': '$nat_virtual_machine',
'vrf_id': '$nat_vrf',
}
filter_fields=[
{'accessor': 'device_id', 'field_name': 'nat_device'},
{'accessor': 'virtual_machine_id', 'field_name': 'nat_virtual_machine'},
{'accessor': 'vrf_id', 'field_name': 'nat_vrf'},
],
)
primary_for_parent = forms.BooleanField(
required=False,
@ -1365,10 +1365,10 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
initial_params={
'locations': '$location'
},
query_params={
'region_id': '$region',
'group_id': '$sitegroup',
}
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region'},
{'accessor': 'group_id', 'field_name': 'sitegroup'},
],
)
location = DynamicModelChoiceField(
queryset=Location.objects.all(),
@ -1376,17 +1376,17 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
initial_params={
'racks': '$rack'
},
query_params={
'site_id': '$site',
}
filter_fields=[
{'accessor': 'site_id', 'field_name': 'site'},
]
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
query_params={
'site_id': '$site',
'location_id': '$location',
}
filter_fields=[
{'accessor': 'site_id', 'field_name': 'site'},
{'accessor': 'location_id', 'field_name': 'location'},
],
)
clustergroup = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
@ -1399,9 +1399,9 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
required=False,
query_params={
'group_id': '$clustergroup',
}
filter_fields=[
{'accessor': 'group_id', 'field_name': 'clustergroup'},
],
)
slug = SlugField()
@ -1541,9 +1541,9 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
query_params={
'scope_type': '$scope_type',
},
filter_fields=[
{'accessor': 'scope_type', 'field_name': 'scope_type'},
],
label='VLAN Group'
)
@ -1568,10 +1568,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=Site.objects.all(),
required=False,
null_option='None',
query_params={
'region_id': '$region',
'group_id': '$sitegroup',
}
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region'},
{'accessor': 'group_id', 'field_name': 'sitegroup'},
]
)
# Other fields
@ -1657,17 +1657,17 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region',
'group_id': '$site_group',
}
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region'},
{'accessor': 'group_id', 'field_name': 'site_group'},
]
)
group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
query_params={
'site_id': '$site'
}
filter_fields=[
{'accessor': 'site_id', 'field_name': 'site'},
]
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
@ -1722,9 +1722,9 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
queryset=Site.objects.all(),
required=False,
null_option='None',
query_params={
'region': '$region'
},
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region'},
],
label=_('Site'),
fetch_trigger='open'
)
@ -1732,9 +1732,9 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
queryset=VLANGroup.objects.all(),
required=False,
null_option='None',
query_params={
'region': '$region'
},
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region'},
],
label=_('VLAN group'),
fetch_trigger='open'
)

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

@ -2,8 +2,9 @@ import queryString from 'query-string';
import debounce from 'just-debounce-it';
import { readableColor } from 'color2k';
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 { FilterFieldMap } from './filterFields';
import {
isTruthy,
hasMore,
@ -11,61 +12,14 @@ import {
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 +35,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 +78,29 @@ 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
* - 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
* 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 filterFields: FilterFieldMap = new FilterFieldMap();
/**
* 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,17 +187,27 @@ class APISelect {
});
// Initialize API query properties.
this.getFilteredBy();
// this.getFilteredBy();
this.getFilterFields();
this.getPathKeys();
for (const filter of this.filterParams.keys()) {
// for (const filter of this.filterParams.keys()) {
// this.updateQueryParams(filter);
// }
for (const filter of this.filterFields.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 [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);
}
}
@ -395,7 +364,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.filterFields.keys(), ...this.pathValues.keys()]);
for (const dep of dependencies) {
const filterElement = document.querySelector(`[name="${dep}"]`);
@ -588,6 +558,12 @@ class APISelect {
this.updateQueryParams(target.name);
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,27 +631,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 +655,29 @@ 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.filterFields.updateValue(fieldName, elementValue);
const current = this.filterFields.get(fieldName);
if (typeof current !== 'undefined') {
const { queryParam, queryValue, includeNull } = current;
let value = [] as Stringifiable[];
if (includeNull) {
value = [...value, null];
}
if (queryValue.length > 0) {
value = [...value, ...queryValue];
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.filterFields.queryParam(fieldName);
if (queryParam !== null) {
this.queryParams.delete(queryParam);
}
}
}
}
@ -798,86 +775,17 @@ class APISelect {
/**
* Determine if a select element 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-filter-fields`, 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'));
/**
* Properly handle preexistence of keys, value types, and deduplication when adding a filter to
* `filterParams`.
*
* _Note: This is an unnamed function so that it can access `this`._
*/
const addFilter = (key: string, value: Stringifiable): void => {
const current = this.filterParams.get(key);
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, ''), '');
} else {
// Value has been fulfilled and is a real value to query.
addFilter(key.replaceAll(keyPattern, ''), item);
}
}
} 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.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);
}
}
}
}
private getFilterFields(): void {
const serialized = this.base.getAttribute('data-filter-fields');
try {
this.filterFields.addFromJson(serialized);
} catch (err) {
console.group(`Unable to determine filter fields for select field '${this.name}'`);
console.warn(err);
console.groupEnd();
}
}
@ -990,9 +898,3 @@ class APISelect {
}
}
}
export function initApiSelect(): void {
for (const select of getElements<HTMLSelectElement>('.netbox-api-select')) {
new APISelect(select);
}
}

View File

@ -0,0 +1,87 @@
import { isTruthy } from '../../util';
import { isDataFilterFields } from './types';
import type { Stringifiable } from 'query-string';
import type { FilterFieldValue } from './types';
/**
* Extension of built-in `Map` to add convenience functions.
*/
export class FilterFieldMap extends Map<string, FilterFieldValue> {
/**
* Get the query parameter key based on field name.
*
* @param fieldName Related field name.
* @returns `queryParam` key.
*/
public queryParam(fieldName: string): Nullable<FilterFieldValue['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): FilterFieldValue['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: FilterFieldValue['queryValue']): boolean {
const current = this.get(fieldName);
if (isTruthy(current)) {
const { queryParam, includeNull } = current;
this.set(fieldName, { queryParam, queryValue, includeNull });
return true;
}
return false;
}
/**
* Populate the underlying map based on the JSON passed in the `data-filter-fields` attribute.
*
* @param json Raw JSON string from `data-filter-fields` 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];
}
}
// Populate the underlying map with the initial data.
this.set(fieldName, { queryParam, queryValue, includeNull });
}
} else {
throw new Error(
`Data from 'data-filter-fields' 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,111 @@
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 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;
}

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

@ -170,9 +170,9 @@ class TenancyForm(forms.Form):
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={
'group_id': '$tenant_group'
}
filter_fields=[
{'accessor': 'group_id', 'field_name': 'tenant_group'}
]
)
@ -188,9 +188,9 @@ class TenancyFilterForm(forms.Form):
queryset=Tenant.objects.all(),
required=False,
null_option='None',
query_params={
'group_id': '$tenant_group_id'
},
filter_fields=[
{'accessor': 'group_id', 'field_name': 'tenant_group'}
],
label=_('Tenant'),
fetch_trigger='open'
)

View File

@ -371,17 +371,20 @@ class DynamicModelChoiceMixin:
choice (optional)
:param str fetch_trigger: The event type which will cause the select element to
fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional)
:param filter_fields: A dictionary or list of dictionaries that define a related
field. Example: `{'accessor': 'group_id', 'field_name': 'tenant_group'}` (optional)
"""
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,
filter_fields=[], *args, **kwargs):
self.query_params = query_params or {}
self.initial_params = initial_params or {}
self.null_option = null_option
self.disabled_indicator = disabled_indicator
self.fetch_trigger = fetch_trigger
self.filter_fields = filter_fields
# to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
# by widget_attrs()
@ -412,6 +415,10 @@ class DynamicModelChoiceMixin:
for key, value in self.query_params.items():
widget.add_query_param(key, value)
# Attach any dynamic query parameters
if len(self.filter_fields) > 0:
widget.add_filter_fields(self.filter_fields)
return attrs
def get_bound_field(self, form, field_name):

View File

@ -1,4 +1,5 @@
import json
from typing import Dict, Sequence, Union
from django import forms
from django.conf import settings
@ -26,6 +27,8 @@ __all__ = (
'TimePicker',
)
JSONPrimitive = Union[str, bool, int, float, None]
class SmallTextarea(forms.Textarea):
"""
@ -142,22 +145,81 @@ class APISelect(SelectWithDisabled):
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 add_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
Add a query parameter with a static value to the API request.
"""
key = f'data-query-param-{name}'
self.add_filter_fields({'accessor': key, 'field_name': key, 'default_value': value})
values = json.loads(self.attrs.get(key, '[]'))
if type(value) in (list, tuple):
values.extend([str(v) for v in value])
else:
values.append(str(value))
def add_filter_fields(self, filter_fields: Union[Dict[str, JSONPrimitive], Sequence[Dict[str, JSONPrimitive]]]) -> None:
"""
Add details about another form field, the value for which should
be added to this APISelect's URL query parameters.
self.attrs[key] = json.dumps(values)
: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.
"""
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=(',', ':'))
class APISelectMultiple(APISelect, forms.SelectMultiple):

View File

@ -126,10 +126,10 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region',
'group_id': '$site_group',
}
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region'},
{'accessor': 'group_id', 'field_name': 'site_group'},
],
)
comments = CommentField()
tags = DynamicModelMultipleChoiceField(
@ -206,10 +206,10 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBul
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region',
'group_id': '$site_group',
}
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region'},
{'accessor': 'group_id', 'field_name': 'site_group'},
]
)
comments = CommentField(
widget=SmallTextarea,
@ -260,10 +260,10 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
queryset=Site.objects.all(),
required=False,
null_option='None',
query_params={
'region_id': '$region_id',
'site_group_id': '$site_group_id',
},
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region_id'},
{'accessor': 'site_group_id', 'field_name': 'site_group'},
],
label=_('Site'),
fetch_trigger='open'
)
@ -291,26 +291,26 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region',
'group_id': '$site_group',
}
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region'},
{'accessor': 'group_id', 'field_name': 'site_group'},
]
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
null_option='None',
query_params={
'site_id': '$site'
}
filter_fields=[
{'accessor': 'site_id', 'field_name': 'site'},
]
)
devices = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
query_params={
'site_id': '$site',
'rack_id': '$rack',
'cluster_id': 'null',
}
filter_fields=[
{'accessor': 'site_id', 'field_name': 'site'},
{'accessor': 'rack_id', 'field_name': 'rack'},
{'accessor': 'cluster_id', 'field_name': 'cluster', 'default_value': None},
]
)
class Meta:
@ -362,16 +362,16 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
)
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
query_params={
'group_id': '$cluster_group'
}
filter_fields=[
{'accessor': 'group_id', 'field_name': 'cluster_group'},
]
)
role = DynamicModelChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
query_params={
"vm_role": "True"
}
filter_fields=[
{'accessor': 'vm_role', 'field_name': 'vm_role', 'default_value': True},
],
)
platform = DynamicModelChoiceField(
queryset=Platform.objects.all(),
@ -510,9 +510,9 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldM
vm_role=True
),
required=False,
query_params={
"vm_role": "True"
}
filter_fields=[
{'accessor': 'vm_role', 'field_name': 'vm_role', 'default_value': True},
]
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
@ -595,10 +595,10 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod
queryset=Site.objects.all(),
required=False,
null_option='None',
query_params={
'region_id': '$region_id',
'group_id': '$site_group_id',
},
filter_fields=[
{'accessor': 'region_id', 'field_name': 'region_id'},
{'accessor': 'group_id', 'field_name': 'site_group_id'},
],
label=_('Site'),
fetch_trigger='open'
)
@ -606,9 +606,9 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod
queryset=DeviceRole.objects.all(),
required=False,
null_option='None',
query_params={
'vm_role': "True"
},
filter_fields=[
{'accessor': 'vm_role', 'field_name': 'vm_role', 'default_value': True},
],
label=_('Role'),
fetch_trigger='open'
)
@ -657,17 +657,17 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
queryset=VLAN.objects.all(),
required=False,
label='Untagged VLAN',
query_params={
'group_id': '$vlan_group',
}
filter_fields=[
{'accessor': 'group_id', 'field_name': 'vlan_group'},
]
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
label='Tagged VLANs',
query_params={
'group_id': '$vlan_group',
}
filter_fields=[
{'accessor': 'group_id', 'field_name': 'vlan_group'},
]
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
@ -718,9 +718,9 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonFo
parent = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
required=False,
query_params={
'virtual_machine_id': '$virtual_machine',
}
filter_fields=[
{'accessor': 'virtual_machine_id', 'field_name': 'virtual_machine'},
]
)
mac_address = forms.CharField(
required=False,
@ -896,9 +896,9 @@ class VMInterfaceFilterForm(BootstrapMixin, forms.Form):
virtual_machine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
query_params={
'cluster_id': '$cluster_id'
},
filter_fields=[
{'accessor': 'cluster_id', 'field_name': 'cluster_id'},
],
label=_('Virtual machine'),
fetch_trigger='open'
)