diff --git a/netbox/project-static/dist/config.js b/netbox/project-static/dist/config.js index feeaacd2f..291dd4aeb 100644 Binary files a/netbox/project-static/dist/config.js and b/netbox/project-static/dist/config.js differ diff --git a/netbox/project-static/dist/config.js.map b/netbox/project-static/dist/config.js.map index 672c0d7a7..e843c88ba 100644 Binary files a/netbox/project-static/dist/config.js.map and b/netbox/project-static/dist/config.js.map differ diff --git a/netbox/project-static/dist/jobs.js b/netbox/project-static/dist/jobs.js index c20046e82..e8ca9e45a 100644 Binary files a/netbox/project-static/dist/jobs.js and b/netbox/project-static/dist/jobs.js differ diff --git a/netbox/project-static/dist/jobs.js.map b/netbox/project-static/dist/jobs.js.map index 4013840f9..98a83549c 100644 Binary files a/netbox/project-static/dist/jobs.js.map and b/netbox/project-static/dist/jobs.js.map differ diff --git a/netbox/project-static/dist/lldp.js b/netbox/project-static/dist/lldp.js index 2cc3f6255..823621db2 100644 Binary files a/netbox/project-static/dist/lldp.js and b/netbox/project-static/dist/lldp.js differ diff --git a/netbox/project-static/dist/lldp.js.map b/netbox/project-static/dist/lldp.js.map index 44d85ee92..2a6e25806 100644 Binary files a/netbox/project-static/dist/lldp.js.map and b/netbox/project-static/dist/lldp.js.map differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 31b84deb8..c55ebf672 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 7e670e1fc..1918ceb17 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/dist/status.js b/netbox/project-static/dist/status.js index 5d1bcd298..62d937941 100644 Binary files a/netbox/project-static/dist/status.js and b/netbox/project-static/dist/status.js differ diff --git a/netbox/project-static/dist/status.js.map b/netbox/project-static/dist/status.js.map index 801f473e5..1d4d1ec9b 100644 Binary files a/netbox/project-static/dist/status.js.map and b/netbox/project-static/dist/status.js.map differ diff --git a/netbox/project-static/src/select/api.ts b/netbox/project-static/src/select/api.ts index 3e1e75252..0e6a189ff 100644 --- a/netbox/project-static/src/select/api.ts +++ b/netbox/project-static/src/select/api.ts @@ -17,12 +17,34 @@ import { findFirstAdjacent, } from '../util'; +import type { Stringifiable } from 'query-string'; import type { Option } from 'slim-select/dist/data'; -type QueryFilter = Map; +/** + * 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; +/** + * 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; + +/** + * 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. @@ -40,11 +62,9 @@ export type Trigger = // 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_id'], + [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_id'], - // Append `_id` to any fields - [new RegExp(/^([A-Za-z0-9]+)(_id)?$/g), '$1_id'], + [new RegExp(/tenant_(group)/g), '$1'], ] as [RegExp, string][]; // Empty placeholder option. @@ -130,7 +150,7 @@ class APISelect { * `1`, `pathValues` would be updated to reflect a `"rack" => 1` mapping. When the query URL is * updated, the URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`. */ - private readonly pathValues: QueryFilter = new Map(); + private readonly pathValues: PathFilter = new Map(); /** * Original API query URL passed via the `data-href` attribute from the server. This is kept so @@ -226,7 +246,7 @@ class APISelect { this.updatePathValues(filter); } - this.queryParams.set('brief', true); + this.queryParams.set('brief', [true]); this.updateQueryUrl(); // Initialize element styling. @@ -608,7 +628,7 @@ class APISelect { private updateQueryUrl(): void { // Create new URL query parameters based on the current state of `queryParams` and create an // updated API query URL. - const query = {} as Dict; + const query = {} as Dict; for (const [key, value] of this.queryParams.entries()) { query[key] = value; } @@ -651,11 +671,32 @@ class APISelect { } } - if (isTruthy(element.value)) { + // 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[]; + + if (element.multiple) { + // If this is a multi-select (form filters, tags, etc.), use all selected options as the value. + elementValue = Array.from(element.options) + .filter(o => o.selected) + .map(o => o.value); + } else if (element.value !== '') { + // If this is single-select (most fields), use the element's value. This seemingly + // redundant/verbose check is mainly for performance, so we're not running the above three + // functions (`Array.from()`, `Array.filter()`, `Array.map()`) every time every select + // field's value changes. + elementValue = [element.value]; + } + + if (elementValue.length > 0) { // If the field has a value, add it to the map. if (this.filterParams.has(id)) { - // If this element is tracking the neighbor element, add its value to the map. - this.queryParams.set(key, element.value); + // If this instance is filtered by the neighbor element, add its value to the map. + this.queryParams.set(key, elementValue); } } else { // Otherwise, delete it (we don't want to send an empty query like `?site_id=`) @@ -771,6 +812,34 @@ class APISelect { .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([...(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); @@ -778,29 +847,33 @@ class APISelect { 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)) { - const replaced = item.replaceAll(pattern, ''); - this.filterParams.set(replaced, ''); + // Value is an unfulfilled variable. + addFilter(item.replaceAll(pattern, ''), ''); } else { - this.filterParams.set(key.replaceAll(keyPattern, ''), item); + // Value has been fulfilled and is a real value to query. + addFilter(key.replaceAll(keyPattern, ''), item); } } } else { if (parsed.match(/^\$.+$/g)) { - const replaced = parsed.replaceAll(pattern, ''); - this.filterParams.set(replaced, ''); + // Value is an unfulfilled variable. + addFilter(parsed.replaceAll(pattern, ''), ''); } else { - this.filterParams.set(key.replaceAll(keyPattern, ''), parsed); + // 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)) { - const replaced = value.replaceAll(pattern, ''); - this.filterParams.set(replaced, ''); + // Value is an unfulfilled variable. + addFilter(value.replaceAll(pattern, ''), ''); } else { - this.filterParams.set(key.replaceAll(keyPattern, ''), value); + // Value has been fulfilled and is a real value to query. + addFilter(key.replaceAll(keyPattern, ''), value); } } } diff --git a/netbox/project-static/src/util.ts b/netbox/project-static/src/util.ts index 4b144fb2f..5c9110091 100644 --- a/netbox/project-static/src/util.ts +++ b/netbox/project-static/src/util.ts @@ -46,11 +46,11 @@ export function slugify(slug: string, chars: number): string { /** * Type guard to determine if a value is not null, undefined, or empty. */ -export function isTruthy( - value: V, -): value is NonNullable { +export function isTruthy(value: V): value is NonNullable { const badStrings = ['', 'null', 'undefined']; - if (typeof value === 'string' && !badStrings.includes(value)) { + if (Array.isArray(value)) { + return value.length > 0; + } else if (typeof value === 'string' && !badStrings.includes(value)) { return true; } else if (typeof value === 'number') { return true;