diff --git a/netbox/project-static/src/global.d.ts b/netbox/project-static/src/global.d.ts index 2f03f7003..76075f813 100644 --- a/netbox/project-static/src/global.d.ts +++ b/netbox/project-static/src/global.d.ts @@ -1,3 +1,7 @@ +type Primitives = string | number | boolean | undefined | null; + +type JSONAble = Primitives | Primitives[] | { [k: string]: JSONAble } | JSONAble[]; + type Nullable = T | null; type APIAnswer = { @@ -16,18 +20,19 @@ type APIError = { type APIObjectBase = { id: number; + display?: string; name: string; url: string; - [k: string]: unknown; + [k: string]: JSONAble; }; -interface APIReference { +type APIReference = { id: number; name: string; slug: string; url: string; _depth: number; -} +}; interface ObjectWithGroup extends APIObjectBase { group: Nullable; diff --git a/netbox/project-static/src/select/api.ts b/netbox/project-static/src/select/api.ts index 1679d5064..6badc3f60 100644 --- a/netbox/project-static/src/select/api.ts +++ b/netbox/project-static/src/select/api.ts @@ -1,6 +1,6 @@ import SlimSelect from 'slim-select'; import queryString from 'query-string'; -import { getApiData, isApiError, getElements } from '../util'; +import { getApiData, isApiError, getElements, isTruthy } from '../util'; import { createToast } from '../toast'; import { setOptionStyles, getFilteredBy, toggle } from './util'; @@ -14,6 +14,8 @@ type WithExclude = { queryParamExclude: string; }; +type ReplaceTuple = [RegExp, string]; + interface CustomSelect> extends HTMLSelectElement { dataset: T; } @@ -30,6 +32,15 @@ function hasExclusions(el: HTMLSelectElement): el is CustomSelect { const DISABLED_ATTRIBUTES = ['occupied'] as string[]; +// 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'], + // For example, a tenant's group relationship field is `group`, but the field name + // is `tenant_group`. + [new RegExp(/.+_(group)/g), '$1_id'], +] as ReplaceTuple[]; + const PLACEHOLDER = { value: '', text: '', @@ -43,15 +54,22 @@ const PLACEHOLDER = { * * @returns Data parsed into SlimSelect options. */ -async function getChoices( +async function getOptions( url: string, - displayField: string, - selectOptions: string[], + select: HTMLSelectElement, disabledOptions: string[], ): Promise { if (url.includes(`{{`)) { return [PLACEHOLDER]; } + + // Get all non-placeholder (empty) options' values. If any exist, it means we're editing an + // existing object. When we fetch options from the API later, we can set any of the options + // contained in this array to `selected`. + const selectOptions = Array.from(select.options) + .filter(option => option.value !== '') + .map(option => option.value); + return getApiData(url).then(data => { if (isApiError(data)) { const toast = createToast('danger', data.exception, data.error); @@ -62,78 +80,110 @@ async function getChoices( const { results } = data; const options = [PLACEHOLDER] as Option[]; - if (results.length !== 0) { - for (const result of results) { - const data = {} as Record; - const value = result.id.toString(); + for (const result of results) { + const text = getDisplayName(result, select); + const data = {} as Record; + const value = result.id.toString(); - // Set any primitive k/v pairs as data attributes on each option. - for (const [k, v] of Object.entries(result)) { - if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) { - const key = k.replaceAll('_', '-'); - data[key] = String(v); - } + // Set any primitive k/v pairs as data attributes on each option. + for (const [k, v] of Object.entries(result)) { + if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) { + const key = k.replaceAll('_', '-'); + data[key] = String(v); } - - let style, selected, disabled; - - // Set pre-selected options. - if (selectOptions.includes(value)) { - selected = true; - } - - // Set option to disabled if it is contained within the disabled array. - if (selectOptions.some(option => disabledOptions.includes(option))) { - disabled = true; - } - - // Set option to disabled if the result contains a matching key and is truthy. - if (DISABLED_ATTRIBUTES.some(key => Object.keys(result).includes(key) && result[key])) { - disabled = true; - } - - const choice = { - value, - text: result[displayField], - data, - style, - selected, - disabled, - } as Option; - - options.push(choice); } + + let style, selected, disabled; + + // Set pre-selected options. + if (selectOptions.includes(value)) { + selected = true; + } + + // Set option to disabled if it is contained within the disabled array. + if (selectOptions.some(option => disabledOptions.includes(option))) { + disabled = true; + } + + // Set option to disabled if the result contains a matching key and is truthy. + if (DISABLED_ATTRIBUTES.some(key => Object.keys(result).includes(key) && result[key])) { + disabled = true; + } + + const option = { + value, + text, + data, + style, + selected, + disabled, + } as Option; + + options.push(option); } return options; }); } +/** + * Find the select element's placeholder text/label. + */ +function getPlaceholder(select: HTMLSelectElement): string { + let placeholder = select.name; + if (select.id) { + const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement; + + // Set the placeholder text to the label value, if it exists. + if (label !== null) { + placeholder = `Select ${label.innerText.trim()}`; + } + } + return placeholder; +} + +/** + * Find this field's display name. + * @param select + * @returns + */ +function getDisplayName(result: APIObjectBase, select: HTMLSelectElement): string { + let displayName = result.display; + + const legacyDisplayProperty = select.getAttribute('display-field'); + + if ( + typeof displayName === 'undefined' && + legacyDisplayProperty !== null && + legacyDisplayProperty in result + ) { + displayName = result[legacyDisplayProperty] as string; + } + + if (!displayName) { + displayName = result.name; + } + + return displayName; +} + +/** + * Initialize select elements that rely on the NetBox API to build their options. + */ export function initApiSelect() { for (const select of getElements('.netbox-select2-api')) { - // Get all non-placeholder (empty) options' values. If any exist, it means we're editing an - // existing object. When we fetch options from the API later, we can set any of the options - // contained in this array to `selected`. - const selectOptions = Array.from(select.options) - .filter(option => option.value !== '') - .map(option => option.value); - - const filteredBy = getFilteredBy(select); - const filterMap = new Map(); - const event = new Event(`netbox.select.load.${select.name}`); + const filterMap = getFilteredBy(select); + // Initialize an event, so other elements relying on this element can subscribe to this + // element's value. + const event = new Event(`netbox.select.onload.${select.name}`); + // Query Parameters - will have attributes added below. + const query = {} as Record; + // List of OTHER elements THIS element relies on for query filtering. + const groupBy = [] as HTMLSelectElement[]; if (isCustomSelect(select)) { let { url } = select.dataset; - const displayField = select.getAttribute('display-field') ?? 'name'; - let placeholder: string = select.name; - if (select.id) { - const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement; - - // Set the placeholder text to the label value, if it exists. - if (label !== null) { - placeholder = `Select ${label.innerText.trim()}`; - } - } + const placeholder = getPlaceholder(select); let disabledOptions = [] as string[]; if (hasExclusions(select)) { @@ -152,6 +202,20 @@ export function initApiSelect() { allowDeselect: true, deselectLabel: ``, placeholder, + onChange() { + const element = instance.slim.container ?? null; + if (element !== null) { + // Reset validity classes if the field was invalid. + if ( + element.classList.contains('is-invalid') || + select.classList.contains('is-invalid') + ) { + select.classList.remove('is-invalid'); + element.classList.remove('is-invalid'); + } + } + select.dispatchEvent(event); + }, }); // Disable the element while data has not been loaded. @@ -162,29 +226,115 @@ export function initApiSelect() { instance.slim.container.classList.remove(className); } + for (let [key, value] of filterMap) { + if (value === '') { + // An empty value is set if the key contains a `$`, indicating reliance on another field. + const elem = document.querySelector(`[name=${key}]`) as HTMLSelectElement; + if (elem !== null) { + groupBy.push(elem); + if (elem.value !== '') { + // If the element's form value exists, add it to the map. + value = elem.value; + filterMap.set(key, elem.value); + } + } + } + + // A non-empty value indicates a static query parameter. + for (const [pattern, replacement] of REPLACE_PATTERNS) { + // Check the query param key to see if we should modify it. + if (key.match(pattern)) { + key = key.replaceAll(pattern, replacement); + } + } + + if (url.includes(`{{`) && value !== '') { + // If the URL contains a Django/Jinja template variable, we need to replace the + // tag with the event's value. + url = url.replaceAll(new RegExp(`{{${key}}}`, 'g'), value); + select.setAttribute('data-url', url); + } + + // Add post-replaced key/value pairs to the query object. + if (isTruthy(value)) { + query[key] = value; + } + } + + url = queryString.stringifyUrl({ url, query }); + + /** + * When the group's selection changes, re-query the dependant element's options, but + * filtered to results matching the group's ID. + * + * @param event Group's DOM event. + */ + function handleEvent(event: Event) { + const target = event.target as HTMLSelectElement; + + if (isTruthy(target.value)) { + let name = target.name; + + for (const [pattern, replacement] of REPLACE_PATTERNS) { + // Check the query param key to see if we should modify it. + if (name.match(pattern)) { + name = name.replaceAll(pattern, replacement); + } + } + + if (url.includes(`{{`) && target.name && target.value) { + // If the URL (still) contains a Django/Jinja template variable, we need to replace + // the tag with the event's value. + url = url.replaceAll(new RegExp(`{{${target.name}}}`, 'g'), target.value); + select.setAttribute('data-url', url); + } + + if (filterMap.get(target.name) === '') { + // Update empty filter map values now that there is a value. + filterMap.set(target.name, target.value); + } + // Add post-replaced key/value pairs to the query object. + query[name] = target.value; + // Create a URL with all relevant query parameters. + url = queryString.stringifyUrl({ url, query }); + } + + // Disable the element while data is loading. + toggle('disable', instance); + // Load new data. + getOptions(url, select, disabledOptions) + .then(data => instance.setData(data)) + .finally(() => { + // Re-enable the element after data has loaded. + toggle('enable', instance); + // Inform any event listeners that data has updated. + select.dispatchEvent(event); + }); + // Stop event bubbling. + event.preventDefault(); + } + + for (const group of groupBy) { + // Re-fetch data when the group changes. + group.addEventListener('change', handleEvent); + + // Subscribe this instance (the child that relies on `group`) to any changes of the + // group's value, so we can re-render options. + select.addEventListener(`netbox.select.onload.${group.name}`, handleEvent); + } + // Load data. - getChoices(url, displayField, selectOptions, disabledOptions) + getOptions(url, select, disabledOptions) .then(options => instance.setData(options)) .finally(() => { // Set option styles, if the field calls for it (color selectors). setOptionStyles(instance); - // Inform any event listeners that data has updated. - select.dispatchEvent(event); // Enable the element after data has loaded. toggle('enable', instance); + // Inform any event listeners that data has updated. + select.dispatchEvent(event); }); - // Reset validity classes if the field was invalid. - instance.onChange = () => { - const element = instance.slim.container ?? null; - if (element !== null) { - if (element.classList.contains('is-invalid') || select.classList.contains('is-invalid')) { - select.classList.remove('is-invalid'); - element.classList.remove('is-invalid'); - } - } - }; - // Set the underlying select element to the same size as the SlimSelect instance. // This is primarily for built-in HTML form validation, which doesn't really work, // but it also makes things seem cleaner in the DOM. @@ -195,80 +345,6 @@ export function initApiSelect() { select.style.display = 'block'; select.style.position = 'absolute'; select.style.pointerEvents = 'none'; - - for (const filter of filteredBy) { - // Find element with the `name` attribute matching this element's filtered-by attribute. - const groupElem = document.querySelector(`[name=${filter}]`) as HTMLSelectElement; - - if (groupElem !== null) { - if (groupElem.value) { - // Add the group's value to the filtered-by map. - filterMap.set(filter, groupElem.value); - // If the URL contains a Django/Jinja template variable tag, we need to replace the tag - // with the event's value. - if (url.includes(`{{`)) { - url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), groupElem.value); - select.setAttribute('data-url', url); - } - } - /** - * When the group's selection changes, re-query the dependant element's options, but - * filtered to results matching the group's ID. - * - * @param event Group's DOM event. - */ - function handleEvent(event: Event) { - let filterUrl = url; - - const target = event.target as HTMLSelectElement; - - if (target.value) { - let filterValue = target.name; - - if (typeof filterValue === 'undefined') { - filterMap.set(target.name, target.value); - } - - if (url.includes(`{{`) && typeof filterValue !== 'undefined') { - // If the URL contains a Django/Jinja template variable tag, we need to replace - // the tag with the event's value. - url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), filterValue); - select.setAttribute('data-url', url); - } - - let queryKey = filterValue; - if (filter?.includes('_group')) { - // For example, a tenant's group relationship field is `group`, but the field - // name is `tenant_group`. - queryKey = 'group'; - } - - if (typeof queryKey !== 'undefined') { - filterUrl = queryString.stringifyUrl({ - url, - query: { [`${queryKey}_id`]: groupElem.value }, - }); - } - } - - // Disable the element while data is loading. - toggle('disable', instance); - // Load new data. - getChoices(filterUrl, displayField, selectOptions, disabledOptions) - .then(data => instance.setData(data)) - .finally(() => { - // Re-enable the element after data has loaded. - toggle('enable', instance); - }); - } - // Re-fetch data when the group changes. - groupElem.addEventListener('change', handleEvent); - - // Subscribe this instance (the child that relies on groupElem) to any changes of the - // group's value, so we can re-render options. - select.addEventListener(`netbox.select.onload.${groupElem.name}`, handleEvent); - } - } } } } diff --git a/netbox/project-static/src/select/util.ts b/netbox/project-static/src/select/util.ts index 68440340d..6e2c0381d 100644 --- a/netbox/project-static/src/select/util.ts +++ b/netbox/project-static/src/select/util.ts @@ -92,38 +92,66 @@ div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"] * If the attribute exists, parse out the raw value. In the above example, this would be `name`. * * @param element Element to scan - * @returns Attribute name, or null if it was not found. + * @returns Map of attributes to values. An empty value indicates a dynamic property that will + * be updated later. */ -export function getFilteredBy(element: T): string[] { +export function getFilteredBy(element: T): Map { const pattern = new RegExp(/\[|\]|"|\$/g); - const keys = Object.keys(element.dataset); - const filteredBy = [] as string[]; + const keyPattern = new RegExp(/data-query-param-/g); + + // Extract data attributes. + const keys = Object.values(element.attributes) + .map(v => v.name) + .filter(v => v.includes('data')); + + const filterMap = new Map(); // Process the URL attribute in a separate loop so that it comes first. for (const key of keys) { - if (key === 'url' && element.dataset.url?.includes(`{{`)) { - /** - * If the URL contains a Django/Jinja template variable tag we need to extract the variable - * name and consider this a field to monitor for changes. - */ - const value = element.dataset.url.match(/\{\{(.+)\}\}/); + const url = element.getAttribute('data-url'); + if (key === 'data-url' && url !== null && url.includes(`{{`)) { + // If the URL contains a Django/Jinja template variable tag we need to extract the variable + // name and consider this a field to monitor for changes. + const value = url.match(/\{\{(.+)\}\}/); if (value !== null) { - filteredBy.push(value[1]); + filterMap.set(value[1], ''); } } } for (const key of keys) { - if (key.includes('queryParam') && key !== 'queryParamExclude') { - const value = element.dataset[key]; - if (typeof value !== 'undefined') { - const parsed = JSON.parse(value) as string | string[]; - if (Array.isArray(parsed)) { - filteredBy.push(parsed[0].replaceAll(pattern, '')); - } else { - filteredBy.push(value.replaceAll(pattern, '')); + if (key.match(keyPattern) && key !== 'data-query-param-exclude') { + const value = element.getAttribute(key); + if (value !== null) { + try { + const parsed = JSON.parse(value) as string | string[]; + if (Array.isArray(parsed)) { + for (const item of parsed) { + if (item.match(/^\$.+$/g)) { + const replaced = item.replaceAll(pattern, ''); + filterMap.set(replaced, ''); + } else { + filterMap.set(key.replaceAll(keyPattern, ''), item); + } + } + } else { + if (parsed.match(/^\$.+$/g)) { + const replaced = parsed.replaceAll(pattern, ''); + filterMap.set(replaced, ''); + } else { + filterMap.set(key.replaceAll(keyPattern, ''), parsed); + } + } + } catch (err) { + console.warn(err); + if (value.match(/^\$.+$/g)) { + const replaced = value.replaceAll(pattern, ''); + filterMap.set(replaced, ''); + } else { + filterMap.set(key.replaceAll(keyPattern, ''), value); + } } } } } - return filteredBy; + return filterMap; } diff --git a/netbox/project-static/src/util.ts b/netbox/project-static/src/util.ts index 1770f9de5..89013bf70 100644 --- a/netbox/project-static/src/util.ts +++ b/netbox/project-static/src/util.ts @@ -4,6 +4,23 @@ export function isApiError(data: Record): data is APIError { return 'error' in data; } +/** + * Type guard to determine if a value is not null, undefined, or empty. + */ +export function isTruthy( + value: V, +): value is NonNullable { + const badStrings = ['', 'null', 'undefined']; + if (typeof value === 'string' && !badStrings.includes(value)) { + return true; + } else if (typeof value === 'number') { + return true; + } else if (typeof value === 'boolean') { + return true; + } + return false; +} + /** * Retrieve the CSRF token from cookie storage. */