diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index d8a0e0009..37ee33cc7 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 702a0af12..f65842504 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/classes/dynamicParamsMap.ts b/netbox/project-static/src/select/classes/dynamicParamsMap.ts index c47535c9f..cadf37e55 100644 --- a/netbox/project-static/src/select/classes/dynamicParamsMap.ts +++ b/netbox/project-static/src/select/classes/dynamicParamsMap.ts @@ -1,7 +1,7 @@ import { isTruthy } from '../../util'; -import { isDataDynamicParams } from '../../select_old/api/types'; +import { isDataDynamicParams } from '../types'; -import type { QueryParam } from '../../select_old/api/types'; +import type { QueryParam } from '../types'; /** * Extension of built-in `Map` to add convenience functions. diff --git a/netbox/project-static/src/select/classes/dynamicTomSelect.ts b/netbox/project-static/src/select/classes/dynamicTomSelect.ts index 13b483dfe..b650b4a86 100644 --- a/netbox/project-static/src/select/classes/dynamicTomSelect.ts +++ b/netbox/project-static/src/select/classes/dynamicTomSelect.ts @@ -6,7 +6,7 @@ import type { Stringifiable } from 'query-string'; import { DynamicParamsMap } from './dynamicParamsMap'; // Transitional -import { QueryFilter, PathFilter } from '../../select_old/api/types' +import { QueryFilter, PathFilter } from '../types' import { getElement, replaceAll } from '../../util'; diff --git a/netbox/project-static/src/select/types.ts b/netbox/project-static/src/select/types.ts new file mode 100644 index 000000000..3ebd65dab --- /dev/null +++ b/netbox/project-static/src/select/types.ts @@ -0,0 +1,66 @@ +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; + +/** + * 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[]; +}; + +/** + * 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; + +/** + * 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; +} diff --git a/netbox/project-static/src/select_old/api/apiSelect.ts b/netbox/project-static/src/select_old/api/apiSelect.ts deleted file mode 100644 index 279340c12..000000000 --- a/netbox/project-static/src/select_old/api/apiSelect.ts +++ /dev/null @@ -1,1002 +0,0 @@ -import { readableColor } from 'color2k'; -import debounce from 'just-debounce-it'; -import { encode } from 'html-entities'; -import queryString from 'query-string'; -import SlimSelect from 'slim-select'; -import { createToast } from '../../bs'; -import { hasUrl, hasExclusions, isTrigger } from '../util'; -import { DynamicParamsMap } from './dynamicParams'; -import { isStaticParams, isOption } from './types'; -import { - hasMore, - hasError, - isTruthy, - getApiData, - getElement, - isApiError, - replaceAll, - createElement, - uniqueByProperty, - findFirstAdjacent, -} from '../../util'; - -import type { Stringifiable } from 'query-string'; -import type { Option } from 'slim-select/dist/data'; -import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types'; - -// Empty placeholder option. -const EMPTY_PLACEHOLDER = { - value: '', - text: '', - placeholder: true, -} as Option; - -// Attributes which if truthy should render the option disabled. -const DISABLED_ATTRIBUTES = ['occupied'] as string[]; - -/** - * 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. - */ -export class APISelect { - /** - * Base `` element. - */ - private readonly allowRefresh: boolean = true; - - /** - * Event to be dispatched when dependent fields' values change. - */ - private readonly loadEvent: InstanceType; - - /** - * Event to be dispatched when the scroll position of this element's optinos list is at the - * bottom. - */ - private readonly bottomEvent: InstanceType; - - /** - * SlimSelect instance for this element. - */ - private readonly slim: InstanceType; - - /** - * Post-parsed URL query parameters for API queries. - */ - 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 - * (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: PathFilter = 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 = ''; - - /** - * Scroll position of options is at the bottom of the list, or not. Used to determine if - * additional options should be fetched from the API. - */ - private atBottom: boolean = false; - - /** - * API URL for additional options, if applicable. `null` indicates no options remain. - */ - private more: Nullable = null; - - /** - * Array of options values which should be considered disabled or static. - */ - private disabledOptions: Array = []; - - /** - * Array of properties which if truthy on an API object should be considered disabled. - */ - private disabledAttributes: Array = DISABLED_ATTRIBUTES; - - constructor(base: HTMLSelectElement) { - // Initialize readonly properties. - this.base = base; - this.name = base.name; - - 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.bottomEvent = new Event(`netbox.select.atbottom.${base.name}`); - - this.placeholder = this.getPlaceholder(); - this.disabledOptions = this.getDisabledOptions(); - this.disabledAttributes = this.getDisabledAttributes(); - - const emptyOption = base.getAttribute('data-empty-option'); - if (isTruthy(emptyOption)) { - this.emptyOption = { - text: emptyOption, - value: '', - }; - } else { - this.emptyOption = EMPTY_PLACEHOLDER; - } - - const nullOption = base.getAttribute('data-null-option'); - if (isTruthy(nullOption)) { - this.nullOption = { - text: nullOption, - value: 'null', - }; - } - - this.slim = new SlimSelect({ - select: this.base, - allowDeselect: true, - deselectLabel: ``, - placeholder: this.placeholder, - searchPlaceholder: 'Filter', - onChange: () => this.handleSlimChange(), - }); - - // Don't close on select if multiple select - if (this.base.multiple) { - this.slim.config.closeOnSelect = false; - } - - // Initialize API query properties. - this.getStaticParams(); - this.getDynamicParams(); - this.getPathKeys(); - - // 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); - } - - // Populate dynamic path values with any form values that are already known. - for (const filter of this.pathValues.keys()) { - this.updatePathValues(filter); - } - - this.queryParams.set('brief', [true]); - this.updateQueryUrl(); - - // Initialize element styling. - this.resetClasses(); - this.setSlimStyles(); - - // Initialize controlling elements. - this.initResetButton(); - - // Add the refresh button to the search element. - this.initRefreshButton(); - - // Add dependency event listeners. - this.addEventListeners(); - - // Determine if the fetch trigger has been set. - const triggerAttr = this.base.getAttribute('data-fetch-trigger'); - - // Determine if this element is part of collapsible element. - const collapse = this.base.closest('.content-container .collapse'); - - if (isTrigger(triggerAttr)) { - this.trigger = triggerAttr; - } else if (collapse !== null) { - this.trigger = 'collapse'; - } else { - this.trigger = 'open'; - } - - switch (this.trigger) { - case 'collapse': - if (collapse !== null) { - // If the element is collapsible but already shown, load the data immediately. - if (collapse.classList.contains('show')) { - Promise.all([this.loadData()]); - } - - // 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()); - } - break; - case 'open': - // If the trigger is 'open', only load API data when the select element is opened. - this.slim.beforeOpen = () => this.loadData(); - break; - case 'load': - // Otherwise, load the data immediately. - Promise.all([this.loadData()]); - break; - } - } - - /** - * This instance's available options. - */ - private get options(): Option[] { - return this.slim.data.data.filter(isOption); - } - - /** - * Apply new options to both the SlimSelect instance and this manager's state. - */ - private set options(optionsIn: Option[]) { - let newOptions = optionsIn; - // Ensure null option is present, if it exists. - if (this.nullOption !== null) { - newOptions = [this.nullOption, ...newOptions]; - } - // Deduplicate options each time they're set. - const deduplicated = uniqueByProperty(newOptions, 'value'); - // Determine if the new options have a placeholder. - const hasPlaceholder = typeof deduplicated.find(o => o.value === '') !== 'undefined'; - // Get the placeholder index (note: if there is no placeholder, the index will be `-1`). - const placeholderIdx = deduplicated.findIndex(o => o.value === ''); - - if (hasPlaceholder && placeholderIdx >= 0) { - // If there is an existing placeholder, replace it. - deduplicated[placeholderIdx] = this.emptyOption; - } else { - // If there is not a placeholder, add one to the front. - deduplicated.unshift(this.emptyOption); - } - this.slim.setData(deduplicated); - } - - /** - * Remove all options and reset back to the generic placeholder. - */ - private resetOptions(): void { - this.options = [this.emptyOption]; - } - - /** - * Add or remove a class to the SlimSelect element to match Bootstrap .form-select:disabled styles. - */ - public disable(): void { - if (this.slim.slim.singleSelected !== null) { - if (!this.slim.slim.singleSelected.container.hasAttribute('disabled')) { - this.slim.slim.singleSelected.container.setAttribute('disabled', ''); - } - } else if (this.slim.slim.multiSelected !== null) { - if (!this.slim.slim.multiSelected.container.hasAttribute('disabled')) { - this.slim.slim.multiSelected.container.setAttribute('disabled', ''); - } - } - this.slim.disable(); - } - - /** - * Add or remove a class to the SlimSelect element to match Bootstrap .form-select:disabled styles. - */ - public enable(): void { - if (this.slim.slim.singleSelected !== null) { - if (this.slim.slim.singleSelected.container.hasAttribute('disabled')) { - this.slim.slim.singleSelected.container.removeAttribute('disabled'); - } - } else if (this.slim.slim.multiSelected !== null) { - if (this.slim.slim.multiSelected.container.hasAttribute('disabled')) { - this.slim.slim.multiSelected.container.removeAttribute('disabled'); - } - } - this.slim.enable(); - } - - /** - * Add event listeners to this element and its dependencies so that when dependencies change - * this element's options are updated. - */ - private addEventListeners(): void { - // Create a debounced function to fetch options based on the search input value. - const fetcher = debounce((event: Event) => this.handleSearch(event), 300, false); - - // Query the API when the input value changes or a value is pasted. - this.slim.slim.search.input.addEventListener('keyup', event => { - // Only search when necessary keys are pressed. - if (!event.key.match(/^(Arrow|Enter|Tab).*/)) { - return fetcher(event); - } - }); - this.slim.slim.search.input.addEventListener('paste', event => fetcher(event)); - - // Watch every scroll event to determine if the scroll position is at bottom. - this.slim.slim.list.addEventListener('scroll', () => this.handleScroll()); - - // When the scroll position is at bottom, fetch additional options. - this.base.addEventListener(`netbox.select.atbottom.${this.name}`, () => - this.fetchOptions(this.more, 'merge'), - ); - - // When the base select element is disabled or enabled, properly disable/enable this instance. - this.base.addEventListener(`netbox.select.disabled.${this.name}`, event => - this.handleDisableEnable(event), - ); - - // Create a unique iterator of all possible form fields which, when changed, should cause this - // element to update its API query. - // 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) { - const filterElement = document.querySelector(`[name="${dep}"]`); - if (filterElement !== null) { - // Subscribe to dependency changes. - filterElement.addEventListener('change', event => this.handleEvent(event)); - } - // Subscribe to changes dispatched by this state manager. - this.base.addEventListener(`netbox.select.onload.${dep}`, event => this.handleEvent(event)); - } - } - - /** - * Load this element's options from the NetBox API. - */ - private async loadData(): Promise { - try { - this.disable(); - await this.getOptions('replace'); - } catch (err) { - console.error(err); - } finally { - this.setOptionStyles(); - this.enable(); - this.base.dispatchEvent(this.loadEvent); - } - } - - /** - * Get all options from the native select element that are already selected and do not contain - * placeholder values. - */ - private getPreselectedOptions(): HTMLOptionElement[] { - return Array.from(this.base.options) - .filter(option => option.selected) - .filter(option => { - if (option.value === '---------' || option.innerText === '---------') return false; - return true; - }); - } - - /** - * Process a valid API response and add results to this instance's options. - * - * @param data Valid API response (not an error). - */ - private async processOptions( - data: APIAnswer, - action: ApplyMethod = 'merge', - ): Promise { - // Get all already-selected options. - const preSelected = this.getPreselectedOptions(); - - // Get the values of all already-selected options. - const selectedValues = preSelected.map(option => option.getAttribute('value')).filter(isTruthy); - - // Build SlimSelect options from all already-selected options. - const preSelectedOptions = preSelected.map(option => ({ - value: option.value, - text: encode(option.innerText), - selected: true, - disabled: false, - })) as Option[]; - - let options = [] as Option[]; - - for (const result of data.results) { - let text = encode(result.display); - - if (typeof result._depth === 'number' && result._depth > 0) { - // If the object has a `_depth` property, indent its display text. - text = `${'─'.repeat(result._depth)} ${text}`; - } - const data = {} as Record; - const value = result.id.toString(); - let style, selected, disabled; - - // 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 = replaceAll(k, '_', '-'); - data[key] = String(v); - } - // Set option to disabled if the result contains a matching key and is truthy. - if (this.disabledAttributes.some(key => key.toLowerCase() === k.toLowerCase())) { - if (typeof v === 'string' && v.toLowerCase() !== 'false') { - disabled = true; - } else if (typeof v === 'boolean' && v === true) { - disabled = true; - } else if (typeof v === 'number' && v > 0) { - disabled = true; - } - } - } - - // Set option to disabled if it is contained within the disabled array. - if (selectedValues.some(option => this.disabledOptions.includes(option))) { - disabled = true; - } - - // Set pre-selected options. - if (selectedValues.includes(value)) { - selected = true; - // If an option is selected, it can't be disabled. Otherwise, it won't be submitted with - // the rest of the form, resulting in that field's value being deleting from the object. - disabled = false; - } - - const option = { - value, - text, - data, - style, - selected, - disabled, - } as Option; - options = [...options, option]; - } - - switch (action) { - case 'merge': - this.options = [...this.options, ...options]; - break; - case 'replace': - this.options = [...preSelectedOptions, ...options]; - break; - } - - if (hasMore(data)) { - // If the `next` property in the API response is a URL, there are more options on the server - // side to be fetched. - this.more = data.next; - } else { - // If the `next` property in the API response is `null`, there are no more options on the - // server, and no additional fetching needs to occur. - this.more = null; - } - } - - /** - * Fetch options from the given API URL and add them to the instance. - * - * @param url API URL - */ - private async fetchOptions(url: Nullable, action: ApplyMethod = 'merge'): Promise { - if (typeof url === 'string') { - const data = await getApiData(url); - - if (hasError(data)) { - if (isApiError(data)) { - return this.handleError(data.exception, data.error); - } - return this.handleError(`Error Fetching Options for field '${this.name}'`, data.error); - } - await this.processOptions(data, action); - } - } - - /** - * Query the NetBox API for this element's options. - */ - private async getOptions(action: ApplyMethod = 'merge'): Promise { - if (this.queryUrl.includes(`{{`)) { - this.resetOptions(); - return; - } - await this.fetchOptions(this.queryUrl, action); - } - - /** - * Query the API for a specific search pattern and add the results to the available options. - */ - private async handleSearch(event: Event) { - const { value: q } = event.target as HTMLInputElement; - const url = queryString.stringifyUrl({ url: this.queryUrl, query: { q } }); - if (!url.includes(`{{`)) { - await this.fetchOptions(url, 'merge'); - this.slim.data.search(q); - this.slim.render(); - } - return; - } - - /** - * Determine if the user has scrolled to the bottom of the options list. If so, try to load - * additional paginated options. - */ - private handleScroll(): void { - // Floor scrollTop as chrome can return fractions on some zoom levels. - const atBottom = - Math.floor(this.slim.slim.list.scrollTop) + this.slim.slim.list.offsetHeight === - this.slim.slim.list.scrollHeight; - - if (this.atBottom && !atBottom) { - this.atBottom = false; - this.base.dispatchEvent(this.bottomEvent); - } else if (!this.atBottom && atBottom) { - this.atBottom = true; - this.base.dispatchEvent(this.bottomEvent); - } - } - - /** - * Event handler to be dispatched any time a dependency's value changes. For example, when the - * value of `tenant_group` changes, `handleEvent` is called to get the current value of - * `tenant_group` and update the query parameters and API query URL for the `tenant` field. - */ - private handleEvent(event: Event): void { - const target = event.target as HTMLSelectElement; - // Update the element's URL after any changes to a dependency. - this.updateQueryParams(target.name); - this.updatePathValues(target.name); - this.updateQueryUrl(); - - // Load new data. - Promise.all([this.loadData()]); - } - - /** - * Event handler to be dispatched when the base select element is disabled or enabled. When that - * occurs, run the instance's `disable()` or `enable()` methods to synchronize UI state with - * desired action. - * - * @param event Dispatched event matching pattern `netbox.select.disabled.` - */ - private handleDisableEnable(event: Event): void { - const target = event.target as HTMLSelectElement; - - if (target.disabled === true) { - this.disable(); - } else if (target.disabled === false) { - this.enable(); - } - } - - /** - * When the API returns an error, show it to the user and reset this element's available options. - * - * @param title Error title - * @param message Error message - */ - private handleError(title: string, message: string): void { - createToast('danger', title, message).show(); - this.resetOptions(); - } - - /** - * `change` event callback to be called any time the value of a SlimSelect instance is changed. - */ - private handleSlimChange(): void { - const element = this.slim.slim; - if (element) { - // Toggle form validation classes when form values change. For example, if the field was - // invalid and the value has now changed, remove the `.is-invalid` class. - if ( - element.container.classList.contains('is-invalid') || - this.base.classList.contains('is-invalid') - ) { - element.container.classList.remove('is-invalid'); - this.base.classList.remove('is-invalid'); - } - } - this.base.dispatchEvent(this.loadEvent); - } - - /** - * Update the API query URL and underlying DOM element's `data-url` attribute. - */ - 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; - for (const [key, value] of this.queryParams.entries()) { - query[key] = value; - } - - let url = this.url; - - // Replace any Django template variables in the URL with values from `pathValues` if set. - for (const [key, value] of this.pathValues.entries()) { - for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) { - if (isTruthy(value)) { - url = replaceAll(url, result[1], value.toString()); - } - } - } - const newUrl = queryString.stringifyUrl({ url, query }); - if (this.queryUrl !== newUrl) { - // Only update the URL if it has changed. - this.queryUrl = newUrl; - this.base.setAttribute('data-url', newUrl); - } - } - - /** - * Update an element's API URL based on the value of another element on which this element - * relies. - * - * @param fieldName DOM ID of the other element. - */ - private updateQueryParams(fieldName: string): void { - // Find the element dependency. - const element = document.querySelector(`[name="${fieldName}"]`); - if (element !== null) { - // 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. - this.dynamicParams.updateValue(fieldName, elementValue); - // Get the updated value. - 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 { - // Otherwise, delete it (we don't want to send an empty query like `?site_id=`) - const queryParam = this.dynamicParams.queryParam(fieldName); - if (queryParam !== null) { - this.queryParams.delete(queryParam); - } - } - } - } - - /** - * Update `pathValues` based on the form value of another element. - * - * @param id DOM ID of the other element. - */ - private updatePathValues(id: string): void { - const key = replaceAll(id, /^id_/i, ''); - const element = getElement(`id_${key}`); - if (element !== null) { - // If this element's URL contains Django template tags ({{), replace the template tag - // with the the dependency's value. For example, if the dependency is the `rack` field, - // and the `rack` field's value is `1`, this element's URL would change from - // `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`. - const hasReplacement = - this.url.includes(`{{`) && Boolean(this.url.match(new RegExp(`({{(${id})}})`, 'g'))); - - if (hasReplacement) { - if (isTruthy(element.value)) { - // If the field has a value, add it to the map. - this.pathValues.set(id, element.value); - } else { - // Otherwise, reset the value. - this.pathValues.set(id, ''); - } - } - } - } - - /** - * Find the select element's placeholder text/label. - */ - private getPlaceholder(): string { - let placeholder = this.name; - if (this.base.id) { - const label = document.querySelector(`label[for="${this.base.id}"]`) as HTMLLabelElement; - // Set the placeholder text to the label value, if it exists. - if (label !== null) { - placeholder = `Select ${label.innerText.trim()}`; - } - } - return placeholder; - } - - /** - * Get this element's disabled options by value. The `data-query-param-exclude` attribute will - * contain a stringified JSON array of option values. - */ - private getDisabledOptions(): string[] { - let disabledOptions = [] as string[]; - if (hasExclusions(this.base)) { - try { - const exclusions = JSON.parse( - this.base.getAttribute('data-query-param-exclude') ?? '[]', - ) as string[]; - disabledOptions = [...disabledOptions, ...exclusions]; - } catch (err) { - console.group( - `Unable to parse data-query-param-exclude value on select element '${this.name}'`, - ); - console.warn(err); - console.groupEnd(); - } - } - return disabledOptions; - } - - /** - * Get this element's disabled attribute keys. For example, if `disabled-indicator` is set to - * `'_occupied'` and an API object contains `{ _occupied: true }`, the option will be disabled. - */ - private getDisabledAttributes(): string[] { - let disabled = [...DISABLED_ATTRIBUTES] as string[]; - const attr = this.base.getAttribute('disabled-indicator'); - if (isTruthy(attr)) { - disabled = [...disabled, attr]; - } - return disabled; - } - - /** - * Parse the `data-url` attribute to add any Django template variables to `pathValues` as keys - * with empty values. As those keys' corresponding form fields' values change, `pathValues` will - * be updated to reflect the new value. - */ - private getPathKeys() { - for (const result of this.url.matchAll(new RegExp(`{{(.+)}}`, 'g'))) { - this.pathValues.set(result[1], ''); - } - } - - /** - * Determine if a this instances' options should be filtered by the value of another select - * element. - * - * Looks for the DOM attribute `data-dynamic-params`, the value of which is a JSON array of - * objects containing information about how to handle the related field. - */ - private getDynamicParams(): void { - const serialized = this.base.getAttribute('data-dynamic-params'); - 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(); - } - } - - /** - * Determine if this instance's options should be filtered by static values passed from the - * server. - * - * 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 { - if (isTruthy(serialized)) { - const deserialized = JSON.parse(serialized); - if (isStaticParams(deserialized)) { - for (const { queryParam, queryValue } of deserialized) { - if (Array.isArray(queryValue)) { - this.staticParams.set(queryParam, queryValue); - } else { - this.staticParams.set(queryParam, [queryValue]); - } - } - } - } - } catch (err) { - console.group(`Unable to determine static query parameters for select field '${this.name}'`); - console.warn(err); - console.groupEnd(); - } - } - - /** - * 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 feel cleaner in the DOM. - */ - private setSlimStyles(): void { - const { width, height } = this.slim.slim.container.getBoundingClientRect(); - this.base.style.opacity = '0'; - this.base.style.width = `${width}px`; - this.base.style.height = `${height}px`; - this.base.style.display = 'block'; - this.base.style.position = 'absolute'; - this.base.style.pointerEvents = 'none'; - } - - /** - * Add scoped style elements specific to each SlimSelect option, if the color property exists. - * As of this writing, this attribute only exist on Tags. The color property is used as the - * background color, and a foreground color is detected based on the luminosity of the background - * color. - */ - private setOptionStyles(): void { - for (const option of this.options) { - // Only create style elements for options that contain a color attribute. - if ( - 'data' in option && - 'id' in option && - typeof option.data !== 'undefined' && - typeof option.id !== 'undefined' && - 'color' in option.data - ) { - const id = option.id as string; - const data = option.data as { color: string }; - - // Create the style element. - const style = document.createElement('style'); - - // Append hash to color to make it a valid hex color. - const bg = `#${data.color}`; - // Detect the foreground color. - const fg = readableColor(bg); - - // Add a unique identifier to the style element. - style.setAttribute('data-netbox', id); - - // Scope the CSS to apply both the list item and the selected item. - style.innerHTML = replaceAll( - ` - div.ss-values div.ss-value[data-id="${id}"], - div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"] - { - background-color: ${bg} !important; - color: ${fg} !important; - } - `, - '\n', - '', - ).trim(); - - // Add the style element to the DOM. - document.head.appendChild(style); - } - } - } - - /** - * Remove base element classes from SlimSelect instance. - */ - private resetClasses(): void { - const element = this.slim.slim; - if (element) { - for (const className of this.base.classList) { - element.container.classList.remove(className); - } - } - } - - /** - * Initialize any adjacent reset buttons so that when clicked, the page is reloaded without - * query parameters. - */ - private initResetButton(): void { - const resetButton = findFirstAdjacent( - this.base, - 'button[data-reset-select]', - ); - if (resetButton !== null) { - resetButton.addEventListener('click', () => { - window.location.assign(window.location.origin + window.location.pathname); - }); - } - } - - /** - * Add a refresh button to the search container element. When clicked, the API data will be - * reloaded. - */ - private initRefreshButton(): void { - if (this.allowRefresh) { - const refreshButton = createElement( - 'button', - { type: 'button' }, - ['btn', 'btn-sm', 'btn-ghost-dark'], - [createElement('i', null, ['mdi', 'mdi-reload'])], - ); - refreshButton.addEventListener('click', () => this.loadData()); - refreshButton.type = 'button'; - this.slim.slim.search.container.appendChild(refreshButton); - } - } -} diff --git a/netbox/project-static/src/select_old/api/dynamicParams.ts b/netbox/project-static/src/select_old/api/dynamicParams.ts deleted file mode 100644 index c31c1962b..000000000 --- a/netbox/project-static/src/select_old/api/dynamicParams.ts +++ /dev/null @@ -1,76 +0,0 @@ -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 { - /** - * Get the query parameter key based on field name. - * - * @param fieldName Related field name. - * @returns `queryParam` key. - */ - public queryParam(fieldName: string): Nullable { - 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}'`, - ); - } - } - } -} diff --git a/netbox/project-static/src/select_old/api/index.ts b/netbox/project-static/src/select_old/api/index.ts deleted file mode 100644 index 3fef1ad6a..000000000 --- a/netbox/project-static/src/select_old/api/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { getElements } from '../../util'; -import { APISelect } from './apiSelect'; - -export function initApiSelect(): void { - for (const select of getElements('.netbox-api-select:not([data-ssid])')) { - new APISelect(select); - } -} - -export type { Trigger } from './types'; diff --git a/netbox/project-static/src/select_old/api/types.ts b/netbox/project-static/src/select_old/api/types.ts deleted file mode 100644 index 8179f4a3a..000000000 --- a/netbox/project-static/src/select_old/api/types.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { Stringifiable } from 'query-string'; -import type { Option, Optgroup } from 'slim-select/dist/data'; - -/** - * 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; - -/** - * 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=""]`) - * - * @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; - /** - * Include `null` on queries for the related field. For example, if `true`, `?=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; - -/** - * 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; -} - -/** - * Type guard to determine if a SlimSelect `dataObject` is an `Option`. - * - * @param data Option or Option Group - */ -export function isOption(data: Option | Optgroup): data is Option { - return !('options' in data); -} diff --git a/netbox/project-static/src/select_old/color.ts b/netbox/project-static/src/select_old/color.ts deleted file mode 100644 index 4c8d6454a..000000000 --- a/netbox/project-static/src/select_old/color.ts +++ /dev/null @@ -1,82 +0,0 @@ -import SlimSelect from 'slim-select'; -import { readableColor } from 'color2k'; -import { getElements } from '../util'; - -import type { Option } from 'slim-select/dist/data'; - -/** - * Determine if the option has a valid value (i.e., is not the placeholder). - */ -function canChangeColor(option: Option | HTMLOptionElement): boolean { - return typeof option.value === 'string' && option.value !== ''; -} - -/** - * Style the container element based on the selected option value. - */ -function styleContainer( - instance: InstanceType, - option: Option | HTMLOptionElement, -): void { - if (instance.slim.singleSelected !== null) { - if (canChangeColor(option)) { - // Get the background color from the selected option's value. - const bg = `#${option.value}`; - // Determine an accessible foreground color based on the background color. - const fg = readableColor(bg); - - // Set the container's style attributes. - instance.slim.singleSelected.container.style.backgroundColor = bg; - instance.slim.singleSelected.container.style.color = fg; - } else { - // If the color cannot be set (i.e., the placeholder), remove any inline styles. - instance.slim.singleSelected.container.removeAttribute('style'); - } - } -} - -/** - * Initialize color selection widget. Dynamically change the style of the select container to match - * the selected option. - */ -export function initColorSelect(): void { - for (const select of getElements( - 'select.netbox-color-select:not([data-ssid])', - )) { - for (const option of select.options) { - if (canChangeColor(option)) { - // Get the background color from the option's value. - const bg = `#${option.value}`; - // Determine an accessible foreground color based on the background color. - const fg = readableColor(bg); - - // Set the option's style attributes. - option.style.backgroundColor = bg; - option.style.color = fg; - } - } - - const instance = new SlimSelect({ - select, - allowDeselect: true, - // Inherit the calculated color on the deselect icon. - deselectLabel: ``, - }); - - // Style the select container to match any pre-selectd options. - for (const option of instance.data.data) { - if ('selected' in option && option.selected) { - styleContainer(instance, option); - break; - } - } - - // Don't inherit the select element's classes. - for (const className of select.classList) { - instance.slim.container.classList.remove(className); - } - - // Change the SlimSelect container's style based on the selected option. - instance.onChange = option => styleContainer(instance, option); - } -} diff --git a/netbox/project-static/src/select_old/index.ts b/netbox/project-static/src/select_old/index.ts deleted file mode 100644 index 356c8004f..000000000 --- a/netbox/project-static/src/select_old/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { initApiSelect } from './api'; -import { initColorSelect } from './color'; -import { initStaticSelect } from './static'; - -export function initSelect(): void { - for (const func of [initApiSelect, initColorSelect, initStaticSelect]) { - func(); - } -} diff --git a/netbox/project-static/src/select_old/static.ts b/netbox/project-static/src/select_old/static.ts deleted file mode 100644 index 19031bb7d..000000000 --- a/netbox/project-static/src/select_old/static.ts +++ /dev/null @@ -1,27 +0,0 @@ -import SlimSelect from 'slim-select'; -import { getElements } from '../util'; - -export function initStaticSelect(): void { - for (const select of getElements('.netbox-static-select:not([data-ssid])')) { - if (select !== null) { - const label = document.querySelector(`label[for="${select.id}"]`) as HTMLLabelElement; - - let placeholder; - if (label !== null) { - placeholder = `Select ${label.innerText.trim()}`; - } - - const instance = new SlimSelect({ - select, - allowDeselect: true, - deselectLabel: ``, - placeholder, - }); - - // Don't copy classes from select element to SlimSelect instance. - for (const className of select.classList) { - instance.slim.container.classList.remove(className); - } - } - } -} diff --git a/netbox/project-static/src/select_old/util.ts b/netbox/project-static/src/select_old/util.ts deleted file mode 100644 index daf7839dc..000000000 --- a/netbox/project-static/src/select_old/util.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Trigger } from './api'; - -/** - * Determine if an element has the `data-url` attribute set. - */ -export function hasUrl(el: HTMLSelectElement): el is HTMLSelectElement & { 'data-url': string } { - const value = el.getAttribute('data-url'); - return typeof value === 'string' && value !== ''; -} - -/** - * Determine if an element has the `data-query-param-exclude` attribute set. - */ -export function hasExclusions( - el: HTMLSelectElement, -): el is HTMLSelectElement & { 'data-query-param-exclude': string } { - const exclude = el.getAttribute('data-query-param-exclude'); - return typeof exclude === 'string' && exclude !== ''; -} - -/** - * Determine if a trigger value is valid. - */ -export function isTrigger(value: unknown): value is Trigger { - return typeof value === 'string' && ['load', 'open', 'collapse'].includes(value); -}