Skip to content

Commit 25d1fe2

Browse files
authored
Improve APISelect query parameter handling (#7040)
* Fixes #7035: Refactor APISelect query_param logic * Add filter_fields to extras.ObjectVar & fix default value handling * Update ObjectVar docs to reflect new filter_fields attribute * Revert changes from 89b7f3f * Maintain current `query_params` API for form fields, transform data structure in widget * Revert changes from d0208d4
1 parent 1a47815 commit 25d1fe2

17 files changed

+532
-214
lines changed

netbox/project-static/dist/config.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/config.js.map

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/jobs.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/jobs.js.map

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/lldp.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/lldp.js.map

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/netbox.js

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/netbox.js.map

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/status.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/status.js.map

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/src/select/api.ts renamed to netbox/project-static/src/select/api/apiSelect.ts

Lines changed: 107 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,26 @@
1-
import queryString from 'query-string';
2-
import debounce from 'just-debounce-it';
31
import { readableColor } from 'color2k';
2+
import debounce from 'just-debounce-it';
3+
import queryString from 'query-string';
44
import SlimSelect from 'slim-select';
5-
import { createToast } from '../bs';
6-
import { hasUrl, hasExclusions, isTrigger } from './util';
5+
import { createToast } from '../../bs';
6+
import { hasUrl, hasExclusions, isTrigger } from '../util';
7+
import { DynamicParamsMap } from './dynamicParams';
8+
import { isStaticParams } from './types';
79
import {
8-
isTruthy,
910
hasMore,
11+
isTruthy,
1012
hasError,
1113
getElement,
1214
getApiData,
1315
isApiError,
14-
getElements,
1516
createElement,
1617
uniqueByProperty,
1718
findFirstAdjacent,
18-
} from '../util';
19+
} from '../../util';
1920

2021
import type { Stringifiable } from 'query-string';
2122
import type { Option } from 'slim-select/dist/data';
22-
23-
/**
24-
* Map of string keys to primitive array values accepted by `query-string`. Keys are used as
25-
* URL query parameter keys. Values correspond to query param values, enforced as an array
26-
* for easier handling. For example, a mapping of `{ site_id: [1, 2] }` is serialized by
27-
* `query-string` as `?site_id=1&site_id=2`. Likewise, `{ site_id: [1] }` is serialized as
28-
* `?site_id=1`.
29-
*/
30-
type QueryFilter = Map<string, Stringifiable[]>;
31-
32-
/**
33-
* Map of string keys to primitive values. Used to track variables within URLs from the server. For
34-
* example, `/api/$key/thing`. `PathFilter` tracks `$key` as `{ key: '' }` in the map, and when the
35-
* value is later known, the value is set — `{ key: 'value' }`, and the URL is transformed to
36-
* `/api/value/thing`.
37-
*/
38-
type PathFilter = Map<string, Stringifiable>;
39-
40-
/**
41-
* Merge or replace incoming options with current options.
42-
*/
43-
type ApplyMethod = 'merge' | 'replace';
44-
45-
/**
46-
* Trigger for which the select instance should fetch its data from the NetBox API.
47-
*/
48-
export type Trigger =
49-
/**
50-
* Load data when the select element is opened.
51-
*/
52-
| 'open'
53-
/**
54-
* Load data when the element is loaded.
55-
*/
56-
| 'load'
57-
/**
58-
* Load data when a parent element is uncollapsed.
59-
*/
60-
| 'collapse';
61-
62-
// Various one-off patterns to replace in query param keys.
63-
const REPLACE_PATTERNS = [
64-
// Don't query `termination_a_device=1`, but rather `device=1`.
65-
[new RegExp(/termination_(a|b)_(.+)/g), '$2'],
66-
// A tenant's group relationship field is `group`, but the field name is `tenant_group`.
67-
[new RegExp(/tenant_(group)/g), '$1'],
68-
] as [RegExp, string][];
23+
import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types';
6924

7025
// Empty placeholder option.
7126
const PLACEHOLDER = {
@@ -81,7 +36,7 @@ const DISABLED_ATTRIBUTES = ['occupied'] as string[];
8136
* Manage a single API-backed select element's state. Each API select element is likely controlled
8237
* or dynamically updated by one or more other API select (or static select) elements' values.
8338
*/
84-
class APISelect {
39+
export class APISelect {
8540
/**
8641
* Base `<select/>` DOM element.
8742
*/
@@ -124,23 +79,31 @@ class APISelect {
12479
*/
12580
private readonly slim: InstanceType<typeof SlimSelect>;
12681

82+
/**
83+
* Post-parsed URL query parameters for API queries.
84+
*/
85+
private readonly queryParams: QueryFilter = new Map();
86+
12787
/**
12888
* API query parameters that should be applied to API queries for this field. This will be
12989
* updated as other dependent fields' values change. This is a mapping of:
13090
*
131-
* Form Field Names → Form Field Values
91+
* Form Field Names → Object containing:
92+
* - Query parameter key name
93+
* - Query value
13294
*
133-
* This is/might be different than the query parameters themselves, as the form field names may
134-
* be different than the object model key names. For example, `tenant_group` would be the field
135-
* name, but `group` would be the query parameter. Query parameters themselves are tracked in
136-
* `queryParams`.
95+
* This is different from `queryParams` in that it tracks all _possible_ related fields and their
96+
* values, even if they are empty. Further, the keys in `queryParams` correspond to the actual
97+
* query parameter keys, which are not necessarily the same as the form field names, depending on
98+
* the model. For example, `tenant_group` would be the field name, but `group_id` would be the
99+
* query parameter.
137100
*/
138-
private readonly filterParams: QueryFilter = new Map();
101+
private readonly dynamicParams: DynamicParamsMap = new DynamicParamsMap();
139102

140103
/**
141-
* Post-parsed URL query parameters for API queries.
104+
* API query parameters that are already known by the server and should not change.
142105
*/
143-
private readonly queryParams: QueryFilter = new Map();
106+
private readonly staticParams: QueryFilter = new Map();
144107

145108
/**
146109
* Mapping of URL template key/value pairs. If this element's URL contains Django template tags
@@ -228,20 +191,21 @@ class APISelect {
228191
});
229192

230193
// Initialize API query properties.
231-
this.getFilteredBy();
194+
this.getStaticParams();
195+
this.getDynamicParams();
232196
this.getPathKeys();
233197

234-
for (const filter of this.filterParams.keys()) {
235-
this.updateQueryParams(filter);
198+
// Populate static query parameters.
199+
for (const [key, value] of this.staticParams.entries()) {
200+
this.queryParams.set(key, value);
236201
}
237202

238-
// Add any already-resolved key/value pairs to the API query parameters.
239-
for (const [key, value] of this.filterParams.entries()) {
240-
if (isTruthy(value)) {
241-
this.queryParams.set(key, value);
242-
}
203+
// Populate dynamic query parameters with any form values that are already known.
204+
for (const filter of this.dynamicParams.keys()) {
205+
this.updateQueryParams(filter);
243206
}
244207

208+
// Populate dynamic path values with any form values that are already known.
245209
for (const filter of this.pathValues.keys()) {
246210
this.updatePathValues(filter);
247211
}
@@ -395,7 +359,8 @@ class APISelect {
395359

396360
// Create a unique iterator of all possible form fields which, when changed, should cause this
397361
// element to update its API query.
398-
const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
362+
// const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
363+
const dependencies = new Set([...this.dynamicParams.keys(), ...this.pathValues.keys()]);
399364

400365
for (const dep of dependencies) {
401366
const filterElement = document.querySelector(`[name="${dep}"]`);
@@ -588,6 +553,7 @@ class APISelect {
588553
this.updateQueryParams(target.name);
589554
this.updatePathValues(target.name);
590555
this.updateQueryUrl();
556+
591557
// Load new data.
592558
Promise.all([this.loadData()]);
593559
}
@@ -655,27 +621,12 @@ class APISelect {
655621
* Update an element's API URL based on the value of another element on which this element
656622
* relies.
657623
*
658-
* @param id DOM ID of the other element.
624+
* @param fieldName DOM ID of the other element.
659625
*/
660-
private updateQueryParams(id: string): void {
661-
let key = id.replaceAll(/^id_/gi, '');
626+
private updateQueryParams(fieldName: string): void {
662627
// Find the element dependency.
663-
const element = getElement<HTMLSelectElement>(`id_${key}`);
628+
const element = document.querySelector<HTMLSelectElement>(`[name="${fieldName}"]`);
664629
if (element !== null) {
665-
// If the dependency has a value, parse the dependency's name (form key) for any
666-
// required replacements.
667-
for (const [pattern, replacement] of REPLACE_PATTERNS) {
668-
if (key.match(pattern)) {
669-
key = key.replaceAll(pattern, replacement);
670-
break;
671-
}
672-
}
673-
674-
// Force related keys to end in `_id`, if they don't already.
675-
if (key.substring(key.length - 3) !== '_id') {
676-
key = `${key}_id`;
677-
}
678-
679630
// Initialize the element value as an array, in case there are multiple values.
680631
let elementValue = [] as Stringifiable[];
681632

@@ -694,13 +645,38 @@ class APISelect {
694645

695646
if (elementValue.length > 0) {
696647
// If the field has a value, add it to the map.
697-
if (this.filterParams.has(id)) {
698-
// If this instance is filtered by the neighbor element, add its value to the map.
699-
this.queryParams.set(key, elementValue);
648+
this.dynamicParams.updateValue(fieldName, elementValue);
649+
// Get the updated value.
650+
const current = this.dynamicParams.get(fieldName);
651+
652+
if (typeof current !== 'undefined') {
653+
const { queryParam, queryValue } = current;
654+
let value = [] as Stringifiable[];
655+
656+
if (this.staticParams.has(queryParam)) {
657+
// If the field is defined in `staticParams`, we should merge the dynamic value with
658+
// the static value.
659+
const staticValue = this.staticParams.get(queryParam);
660+
if (typeof staticValue !== 'undefined') {
661+
value = [...staticValue, ...queryValue];
662+
}
663+
} else {
664+
// If the field is _not_ defined in `staticParams`, we should replace the current value
665+
// with the new dynamic value.
666+
value = queryValue;
667+
}
668+
if (value.length > 0) {
669+
this.queryParams.set(queryParam, value);
670+
} else {
671+
this.queryParams.delete(queryParam);
672+
}
700673
}
701674
} else {
702675
// Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
703-
this.queryParams.delete(key);
676+
const queryParam = this.dynamicParams.queryParam(fieldName);
677+
if (queryParam !== null) {
678+
this.queryParams.delete(queryParam);
679+
}
704680
}
705681
}
706682
}
@@ -796,88 +772,50 @@ class APISelect {
796772
}
797773

798774
/**
799-
* Determine if a select element should be filtered by the value of another select element.
775+
* Determine if a this instances' options should be filtered by the value of another select
776+
* element.
800777
*
801-
* Looks for the DOM attribute `data-query-param-<name of other field>`, which would look like:
802-
* `["$<name>"]`
778+
* Looks for the DOM attribute `data-dynamic-params`, the value of which is a JSON array of
779+
* objects containing information about how to handle the related field.
780+
*/
781+
private getDynamicParams(): void {
782+
const serialized = this.base.getAttribute('data-dynamic-params');
783+
try {
784+
this.dynamicParams.addFromJson(serialized);
785+
} catch (err) {
786+
console.group(`Unable to determine dynamic query parameters for select field '${this.name}'`);
787+
console.warn(err);
788+
console.groupEnd();
789+
}
790+
}
791+
792+
/**
793+
* Determine if this instance's options should be filtered by static values passed from the
794+
* server.
803795
*
804-
* If the attribute exists, parse out the raw value. In the above example, this would be `name`.
805-
*/
806-
private getFilteredBy(): void {
807-
const pattern = new RegExp(/\[|\]|"|\$/g);
808-
const keyPattern = new RegExp(/data-query-param-/g);
809-
810-
// Extract data attributes.
811-
const keys = Object.values(this.base.attributes)
812-
.map(v => v.name)
813-
.filter(v => v.includes('data'));
814-
815-
/**
816-
* Properly handle preexistence of keys, value types, and deduplication when adding a filter to
817-
* `filterParams`.
818-
*
819-
* _Note: This is an unnamed function so that it can access `this`._
820-
*/
821-
const addFilter = (key: string, value: Stringifiable): void => {
822-
const current = this.filterParams.get(key);
823-
824-
if (typeof current !== 'undefined') {
825-
// This instance is already filtered by `key`, so we should add the new `value`.
826-
// Merge and deduplicate the current filter parameter values with the incoming value.
827-
const next = Array.from(
828-
new Set<Stringifiable>([...(current as Stringifiable[]), value]),
829-
);
830-
this.filterParams.set(key, next);
831-
} else {
832-
// This instance is not already filtered by `key`, so we should add a new mapping.
833-
if (value === '') {
834-
// Don't add placeholder values.
835-
this.filterParams.set(key, []);
836-
} else {
837-
// If the value is not a placeholder, add it.
838-
this.filterParams.set(key, [value]);
839-
}
840-
}
841-
};
842-
843-
for (const key of keys) {
844-
if (key.match(keyPattern) && key !== 'data-query-param-exclude') {
845-
const value = this.base.getAttribute(key);
846-
if (value !== null) {
847-
try {
848-
const parsed = JSON.parse(value) as string | string[];
849-
if (Array.isArray(parsed)) {
850-
// Query param contains multiple values.
851-
for (const item of parsed) {
852-
if (item.match(/^\$.+$/g)) {
853-
// Value is an unfulfilled variable.
854-
addFilter(item.replaceAll(pattern, ''), '');
855-
} else {
856-
// Value has been fulfilled and is a real value to query.
857-
addFilter(key.replaceAll(keyPattern, ''), item);
858-
}
859-
}
860-
} else {
861-
if (parsed.match(/^\$.+$/g)) {
862-
// Value is an unfulfilled variable.
863-
addFilter(parsed.replaceAll(pattern, ''), '');
864-
} else {
865-
// Value has been fulfilled and is a real value to query.
866-
addFilter(key.replaceAll(keyPattern, ''), parsed);
867-
}
868-
}
869-
} catch (err) {
870-
console.warn(err);
871-
if (value.match(/^\$.+$/g)) {
872-
// Value is an unfulfilled variable.
873-
addFilter(value.replaceAll(pattern, ''), '');
796+
* Looks for the DOM attribute `data-static-params`, the value of which is a JSON array of
797+
* objects containing key/value pairs to add to `this.staticParams`.
798+
*/
799+
private getStaticParams(): void {
800+
const serialized = this.base.getAttribute('data-static-params');
801+
802+
try {
803+
if (isTruthy(serialized)) {
804+
const deserialized = JSON.parse(serialized);
805+
if (isStaticParams(deserialized)) {
806+
for (const { queryParam, queryValue } of deserialized) {
807+
if (Array.isArray(queryValue)) {
808+
this.staticParams.set(queryParam, queryValue);
874809
} else {
875-
// Value has been fulfilled and is a real value to query.
876-
addFilter(key.replaceAll(keyPattern, ''), value);
810+
this.staticParams.set(queryParam, [queryValue]);
877811
}
878812
}
879813
}
880814
}
815+
} catch (err) {
816+
console.group(`Unable to determine static query parameters for select field '${this.name}'`);
817+
console.warn(err);
818+
console.groupEnd();
881819
}
882820
}
883821

@@ -990,9 +928,3 @@ class APISelect {
990928
}
991929
}
992930
}
993-
994-
export function initApiSelect(): void {
995-
for (const select of getElements<HTMLSelectElement>('.netbox-api-select')) {
996-
new APISelect(select);
997-
}
998-
}

0 commit comments

Comments
 (0)