mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-15 11:42:52 -06:00
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 from89b7f3f
* Maintain current `query_params` API for form fields, transform data structure in widget * Revert changes fromd0208d4
This commit is contained in:
parent
1a478150d6
commit
25d1fe2c8d
BIN
netbox/project-static/dist/config.js
vendored
BIN
netbox/project-static/dist/config.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/config.js.map
vendored
BIN
netbox/project-static/dist/config.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/jobs.js
vendored
BIN
netbox/project-static/dist/jobs.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/jobs.js.map
vendored
BIN
netbox/project-static/dist/jobs.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/lldp.js
vendored
BIN
netbox/project-static/dist/lldp.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/lldp.js.map
vendored
BIN
netbox/project-static/dist/lldp.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/status.js
vendored
BIN
netbox/project-static/dist/status.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/status.js.map
vendored
BIN
netbox/project-static/dist/status.js.map
vendored
Binary file not shown.
@ -1,71 +1,26 @@
|
|||||||
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 { DynamicParamsMap } from './dynamicParams';
|
||||||
|
import { isStaticParams } from './types';
|
||||||
import {
|
import {
|
||||||
isTruthy,
|
|
||||||
hasMore,
|
hasMore,
|
||||||
|
isTruthy,
|
||||||
hasError,
|
hasError,
|
||||||
getElement,
|
getElement,
|
||||||
getApiData,
|
getApiData,
|
||||||
isApiError,
|
isApiError,
|
||||||
getElements,
|
|
||||||
createElement,
|
createElement,
|
||||||
uniqueByProperty,
|
uniqueByProperty,
|
||||||
findFirstAdjacent,
|
findFirstAdjacent,
|
||||||
} from '../util';
|
} from '../../util';
|
||||||
|
|
||||||
import type { Stringifiable } from 'query-string';
|
import type { Stringifiable } from 'query-string';
|
||||||
import type { Option } from 'slim-select/dist/data';
|
import type { Option } from 'slim-select/dist/data';
|
||||||
|
import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types';
|
||||||
/**
|
|
||||||
* 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][];
|
|
||||||
|
|
||||||
// Empty placeholder option.
|
// Empty placeholder option.
|
||||||
const PLACEHOLDER = {
|
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
|
* 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.
|
* or dynamically updated by one or more other API select (or static select) elements' values.
|
||||||
*/
|
*/
|
||||||
class APISelect {
|
export class APISelect {
|
||||||
/**
|
/**
|
||||||
* Base `<select/>` DOM element.
|
* Base `<select/>` DOM element.
|
||||||
*/
|
*/
|
||||||
@ -124,24 +79,32 @@ class APISelect {
|
|||||||
*/
|
*/
|
||||||
private readonly slim: InstanceType<typeof SlimSelect>;
|
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.
|
* Post-parsed URL query parameters for API queries.
|
||||||
*/
|
*/
|
||||||
private readonly queryParams: QueryFilter = new Map();
|
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
|
* 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
|
* (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.
|
// Initialize API query properties.
|
||||||
this.getFilteredBy();
|
this.getStaticParams();
|
||||||
|
this.getDynamicParams();
|
||||||
this.getPathKeys();
|
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);
|
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 filter of this.pathValues.keys()) {
|
for (const filter of this.pathValues.keys()) {
|
||||||
this.updatePathValues(filter);
|
this.updatePathValues(filter);
|
||||||
}
|
}
|
||||||
@ -395,7 +359,8 @@ 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.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}"]`);
|
||||||
@ -588,6 +553,7 @@ class APISelect {
|
|||||||
this.updateQueryParams(target.name);
|
this.updateQueryParams(target.name);
|
||||||
this.updatePathValues(target.name);
|
this.updatePathValues(target.name);
|
||||||
this.updateQueryUrl();
|
this.updateQueryUrl();
|
||||||
|
|
||||||
// Load new data.
|
// Load new data.
|
||||||
Promise.all([this.loadData()]);
|
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
|
* Update an element's API URL based on the value of another element on which this element
|
||||||
* relies.
|
* relies.
|
||||||
*
|
*
|
||||||
* @param id DOM ID of the other element.
|
* @param fieldName DOM ID of the other element.
|
||||||
*/
|
*/
|
||||||
private updateQueryParams(id: string): void {
|
private updateQueryParams(fieldName: string): void {
|
||||||
let key = id.replaceAll(/^id_/gi, '');
|
|
||||||
// Find the element dependency.
|
// Find the element dependency.
|
||||||
const element = getElement<HTMLSelectElement>(`id_${key}`);
|
const element = document.querySelector<HTMLSelectElement>(`[name="${fieldName}"]`);
|
||||||
if (element !== null) {
|
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.
|
// Initialize the element value as an array, in case there are multiple values.
|
||||||
let elementValue = [] as Stringifiable[];
|
let elementValue = [] as Stringifiable[];
|
||||||
|
|
||||||
@ -694,13 +645,38 @@ 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.
|
||||||
if (this.filterParams.has(id)) {
|
this.dynamicParams.updateValue(fieldName, elementValue);
|
||||||
// If this instance is filtered by the neighbor element, add its value to the map.
|
// Get the updated value.
|
||||||
this.queryParams.set(key, elementValue);
|
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 {
|
} 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=`)
|
||||||
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:
|
* Looks for the DOM attribute `data-dynamic-params`, the value of which is a JSON array of
|
||||||
* `["$<name>"]`
|
* objects containing information about how to handle the related field.
|
||||||
*
|
|
||||||
* If the attribute exists, parse out the raw value. In the above example, this would be `name`.
|
|
||||||
*/
|
*/
|
||||||
private getFilteredBy(): void {
|
private getDynamicParams(): void {
|
||||||
const pattern = new RegExp(/\[|\]|"|\$/g);
|
const serialized = this.base.getAttribute('data-dynamic-params');
|
||||||
const keyPattern = new RegExp(/data-query-param-/g);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extract data attributes.
|
/**
|
||||||
const keys = Object.values(this.base.attributes)
|
* Determine if this instance's options should be filtered by static values passed from the
|
||||||
.map(v => v.name)
|
* server.
|
||||||
.filter(v => v.includes('data'));
|
*
|
||||||
|
* 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 {
|
||||||
* Properly handle preexistence of keys, value types, and deduplication when adding a filter to
|
if (isTruthy(serialized)) {
|
||||||
* `filterParams`.
|
const deserialized = JSON.parse(serialized);
|
||||||
*
|
if (isStaticParams(deserialized)) {
|
||||||
* _Note: This is an unnamed function so that it can access `this`._
|
for (const { queryParam, queryValue } of deserialized) {
|
||||||
*/
|
if (Array.isArray(queryValue)) {
|
||||||
const addFilter = (key: string, value: Stringifiable): void => {
|
this.staticParams.set(queryParam, queryValue);
|
||||||
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 {
|
} else {
|
||||||
if (parsed.match(/^\$.+$/g)) {
|
this.staticParams.set(queryParam, [queryValue]);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.group(`Unable to determine static query parameters for select field '${this.name}'`);
|
||||||
|
console.warn(err);
|
||||||
|
console.groupEnd();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -990,9 +928,3 @@ class APISelect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initApiSelect(): void {
|
|
||||||
for (const select of getElements<HTMLSelectElement>('.netbox-api-select')) {
|
|
||||||
new APISelect(select);
|
|
||||||
}
|
|
||||||
}
|
|
76
netbox/project-static/src/select/api/dynamicParams.ts
Normal file
76
netbox/project-static/src/select/api/dynamicParams.ts
Normal 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}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
netbox/project-static/src/select/api/index.ts
Normal file
10
netbox/project-static/src/select/api/index.ts
Normal 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';
|
189
netbox/project-static/src/select/api/types.ts
Normal file
189
netbox/project-static/src/select/api/types.ts
Normal 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;
|
||||||
|
}
|
@ -39,6 +39,8 @@ export function isTruthy<V extends unknown>(value: V): value is NonNullable<V> {
|
|||||||
return true;
|
return true;
|
||||||
} else if (typeof value === 'boolean') {
|
} else if (typeof value === 'boolean') {
|
||||||
return true;
|
return true;
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -375,8 +375,8 @@ class DynamicModelChoiceMixin:
|
|||||||
filter = django_filters.ModelChoiceFilter
|
filter = django_filters.ModelChoiceFilter
|
||||||
widget = widgets.APISelect
|
widget = widgets.APISelect
|
||||||
|
|
||||||
def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, fetch_trigger=None, *args,
|
def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, fetch_trigger=None,
|
||||||
**kwargs):
|
*args, **kwargs):
|
||||||
self.query_params = query_params or {}
|
self.query_params = query_params or {}
|
||||||
self.initial_params = initial_params or {}
|
self.initial_params = initial_params or {}
|
||||||
self.null_option = null_option
|
self.null_option = null_option
|
||||||
@ -409,8 +409,8 @@ class DynamicModelChoiceMixin:
|
|||||||
attrs['data-fetch-trigger'] = self.fetch_trigger
|
attrs['data-fetch-trigger'] = self.fetch_trigger
|
||||||
|
|
||||||
# Attach any static query parameters
|
# Attach any static query parameters
|
||||||
for key, value in self.query_params.items():
|
if (len(self.query_params) > 0):
|
||||||
widget.add_query_param(key, value)
|
widget.add_query_params(self.query_params)
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
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
|
||||||
@ -26,6 +27,11 @@ __all__ = (
|
|||||||
'TimePicker',
|
'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):
|
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.
|
: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, 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.
|
Based on query param value's type and value, update instance's dynamic/static params.
|
||||||
|
|
||||||
:param name: The name of the query param
|
|
||||||
:param value: The value of the query param
|
|
||||||
"""
|
"""
|
||||||
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, '[]'))
|
# Check type of `value` again, since it may have changed.
|
||||||
if type(value) in (list, tuple):
|
if isinstance(value, str):
|
||||||
values.extend([str(v) for v in value])
|
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:
|
else:
|
||||||
values.append(str(value))
|
# 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):
|
class APISelectMultiple(APISelect, forms.SelectMultiple):
|
||||||
|
Loading…
Reference in New Issue
Block a user