diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 8496f5e82..aecf046bb 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2077,6 +2077,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): attrs={ 'disabled-indicator': 'device', 'data-query-param-face': "[\"$face\"]", + # The UI will not sort this element's options. + 'pre-sorted': '' } ) ) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index dc5478c18..355bf877d 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 035eafbc3..09f0dc78c 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/src/select/api.ts b/netbox/project-static/src/select/api.ts index 514ec563c..2e0636a73 100644 --- a/netbox/project-static/src/select/api.ts +++ b/netbox/project-static/src/select/api.ts @@ -1,41 +1,21 @@ -import SlimSelect from 'slim-select'; import queryString from 'query-string'; +import { readableColor } from 'color2k'; +import SlimSelect from 'slim-select'; +import { createToast } from '../bs'; +import { hasUrl, hasExclusions } from './util'; import { + isTruthy, + hasError, + getElement, getApiData, isApiError, getElements, - isTruthy, - hasError, findFirstAdjacent, } from '../util'; -import { createToast } from '../bs'; -import { setOptionStyles, toggle, getDependencyIds, initResetButton } from './util'; import type { Option } from 'slim-select/dist/data'; -type WithUrl = { - 'data-url': string; -}; - -type WithExclude = { - queryParamExclude: string; -}; - -type ReplaceTuple = [RegExp, string]; - -type CustomSelect> = HTMLSelectElement & T; - -function hasUrl(el: HTMLSelectElement): el is CustomSelect { - const value = el.getAttribute('data-url'); - return typeof value === 'string' && value !== ''; -} - -function hasExclusions(el: HTMLSelectElement): el is CustomSelect { - const exclude = el.getAttribute('data-query-param-exclude'); - return typeof exclude === 'string' && exclude !== ''; -} - -const DISABLED_ATTRIBUTES = ['occupied'] as string[]; +type QueryFilter = Map; // Various one-off patterns to replace in query param keys. const REPLACE_PATTERNS = [ @@ -45,335 +25,700 @@ const REPLACE_PATTERNS = [ [new RegExp(/tenant_(group)/g), '$1_id'], // Append `_id` to any fields [new RegExp(/^([A-Za-z0-9]+)(_id)?$/g), '$1_id'], -] as ReplaceTuple[]; +] as [RegExp, string][]; +// Empty placeholder option. const PLACEHOLDER = { value: '', text: '', placeholder: true, } as Option; +// Attributes which if truthy should render the option disabled. +const DISABLED_ATTRIBUTES = ['occupied'] as string[]; + /** - * Retrieve all objects for this object type. - * - * @param url API endpoint to query. - * - * @returns Data parsed into SlimSelect options. + * 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. */ -async function getOptions( - url: string, - select: HTMLSelectElement, - disabledOptions: string[], -): Promise { - if (url.includes(`{{`)) { - return [PLACEHOLDER]; - } +class APISelect { + /** + * Base `` element. + */ + private readonly preSorted: boolean = false; + + /** + * Event to be dispatched when dependent fields' values change. + */ + private readonly loadEvent: InstanceType; + + /** + * SlimSelect instance for this element. + */ + private readonly slim: InstanceType; + + /** + * 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(); + + /** + * 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 + * tracked for changes. When the `id_key` element's value changes, the new value will be added + * to this map. For example, if the template key is `rack`, and the `id_rack` field's value is + * `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(); + + /** + * Original API query URL passed via the `data-href` attribute from the server. This is kept so + * that the URL can be reconstructed as form values change. + */ + private readonly url: string = ''; + + /** + * API query URL. This will be updated dynamically to include any query parameters in `queryParameters`. + */ + private queryUrl: string = ''; + + /** + * This instance's available options. + */ + private _options: Option[] = [PLACEHOLDER]; + + /** + * Array of options values which should be considered disabled or static. + */ + private disabledOptions: Array = []; + + constructor(base: HTMLSelectElement) { + // Initialize readonly properties. + this.base = base; + this.name = base.name; + + if (base.getAttribute('pre-sorted') !== null) { + this.preSorted = true; + } + + if (hasUrl(base)) { + const url = base.getAttribute('data-url') as string; + this.url = url; + this.queryUrl = url; + } + + this.loadEvent = new Event(`netbox.select.onload.${base.name}`); + this.placeholder = this.getPlaceholder(); + this.disabledOptions = this.getDisabledOptions(); + + this.slim = new SlimSelect({ + select: this.base, + allowDeselect: true, + deselectLabel: ``, + placeholder: this.placeholder, + onChange: () => this.handleSlimChange(), + }); + + // Initialize API query properties. + this.getFilteredBy(); + this.getPathKeys(); + + for (const filter of this.filterParams.keys()) { + this.updateQueryParams(filter); + } + + for (const filter of this.pathValues.keys()) { + this.updatePathValues(filter); + } + + this.queryParams.set('limit', 0); + this.updateQueryUrl(); + + // Initialize element styling. + this.resetClasses(); + this.setSlimStyles(); + + // Initialize controlling elements. + this.initResetButton(); + + // Add dependency event listeners. + this.addEventListeners(); + + // Determine if this element is part of collapsible element. + const collapse = findFirstAdjacent(this.base, '.collapse', '.content-container'); + if (collapse !== null) { + // If this element is part of a collapsible element, only load the data when the + // collapsible element is shown. + // See: https://getbootstrap.com/docs/5.0/components/collapse/#events + collapse.addEventListener('show.bs.collapse', () => this.loadData()); + collapse.addEventListener('hide.bs.collapse', () => this.resetOptions()); + } else { + // Otherwise, load the data on render. + Promise.all([this.loadData()]); } - createToast('danger', `Error Fetching Options for field ${select.name}`, data.error).show(); - return [PLACEHOLDER]; } - const { results } = data; - const options = [PLACEHOLDER] as Option[]; + /** + * This instance's available options. + */ + public get options(): Option[] { + return this._options; + } - for (const result of results) { - const text = getDisplayName(result, select); - const data = {} as Record; - const value = result.id.toString(); - let style, selected, disabled; + /** + * Sort incoming options by label and apply the new options to both the SlimSelect instance and + * this manager's state. If the `preSorted` attribute exists on the base `