diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index f21bd3f87..0c164ac29 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -119,10 +119,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): user = DynamicModelMultipleChoiceField( queryset=get_user_model().objects.all(), required=False, - label=_('User'), - widget=APISelectMultiple( - api_url='/api/users/users/', - ) + label=_('User') ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 95c441381..89793528d 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -393,10 +393,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): user_id = DynamicModelMultipleChoiceField( queryset=get_user_model().objects.all(), required=False, - label=_('User'), - widget=APISelectMultiple( - api_url='/api/users/users/', - ) + label=_('User') ) tag = TagFilterField(model) @@ -551,8 +548,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, - label=_('Manufacturer'), - fetch_trigger='open' + label=_('Manufacturer') ) part_number = forms.CharField( label=_('Part number'), @@ -828,8 +824,7 @@ class VirtualDeviceContextFilterForm( device = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label=_('Device'), - fetch_trigger='open' + label=_('Device') ) status = forms.MultipleChoiceField( label=_('Status'), @@ -855,8 +850,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, - label=_('Manufacturer'), - fetch_trigger='open' + label=_('Manufacturer') ) module_type_id = DynamicModelMultipleChoiceField( queryset=ModuleType.objects.all(), @@ -864,8 +858,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo query_params={ 'manufacturer_id': '$manufacturer_id' }, - label=_('Type'), - fetch_trigger='open' + label=_('Type') ) status = forms.MultipleChoiceField( label=_('Status'), @@ -1414,8 +1407,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): role_id = DynamicModelMultipleChoiceField( queryset=InventoryItemRole.objects.all(), required=False, - label=_('Role'), - fetch_trigger='open' + label=_('Role') ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index c91e3b8c6..3a6421901 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -381,8 +381,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): cluster_type_id = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), required=False, - label=_('Cluster types'), - fetch_trigger='open' + label=_('Cluster types') ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), @@ -462,10 +461,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): created_by_id = DynamicModelMultipleChoiceField( queryset=get_user_model().objects.all(), required=False, - label=_('User'), - widget=APISelectMultiple( - api_url='/api/users/users/', - ) + label=_('User') ) assigned_object_type_id = DynamicModelMultipleChoiceField( queryset=ContentType.objects.all(), @@ -508,10 +504,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): user_id = DynamicModelMultipleChoiceField( queryset=get_user_model().objects.all(), required=False, - label=_('User'), - widget=APISelectMultiple( - api_url='/api/users/users/', - ) + label=_('User') ) changed_object_type_id = DynamicModelMultipleChoiceField( queryset=ContentType.objects.all(), diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 194e79351..48a70fd10 100644 Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 5146f855f..50b9ca705 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 c28525be9..431f5e626 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/package-lock.json b/netbox/project-static/package-lock.json index 1c40ad5a3..36401ecd0 100644 --- a/netbox/project-static/package-lock.json +++ b/netbox/project-static/package-lock.json @@ -25,6 +25,7 @@ "query-string": "^7.1.1", "sass": "^1.55.0", "slim-select": "^1.27.1", + "tom-select": "^2.3.1", "typeface-inter": "^3.18.1", "typeface-roboto-mono": "^1.1.13" }, @@ -225,6 +226,19 @@ "node": ">= 8" } }, + "node_modules/@orchidjs/sifter": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.0.3.tgz", + "integrity": "sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g==", + "dependencies": { + "@orchidjs/unicode-variants": "^1.0.4" + } + }, + "node_modules/@orchidjs/unicode-variants": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz", + "integrity": "sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ==" + }, "node_modules/@pkgr/utils": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz", @@ -3888,6 +3902,22 @@ "integrity": "sha1-bkWxJj8gF/oKzH2J14sVuL932jI=", "license": "MIT" }, + "node_modules/tom-select": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.3.1.tgz", + "integrity": "sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg==", + "dependencies": { + "@orchidjs/sifter": "^1.0.3", + "@orchidjs/unicode-variants": "^1.0.4" + }, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tom-select" + } + }, "node_modules/tsconfig-paths": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json index a46612d55..e29d639fe 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -31,16 +31,16 @@ "gridstack": "^7.2.3", "html-entities": "^2.3.3", "htmx.org": "^1.8.0", - "just-debounce-it": "^3.1.1", "query-string": "^7.1.1", "sass": "^1.55.0", - "slim-select": "^1.27.1", + "tom-select": "^2.3.1", "typeface-inter": "^3.18.1", "typeface-roboto-mono": "^1.1.13" }, "devDependencies": { "@types/bootstrap": "5.2.10", "@types/cookie": "^0.5.1", + "@types/node": "^20.11.16", "@typescript-eslint/eslint-plugin": "^5.39.0", "@typescript-eslint/parser": "^5.39.0", "esbuild": "^0.13.15", diff --git a/netbox/project-static/src/htmx.ts b/netbox/project-static/src/htmx.ts index 2410a5fd9..8d92b60c8 100644 --- a/netbox/project-static/src/htmx.ts +++ b/netbox/project-static/src/htmx.ts @@ -1,11 +1,11 @@ import { getElements, isTruthy } from './util'; import { initButtons } from './buttons'; -import { initSelect } from './select'; +import { initSelects } from './select'; import { initObjectSelector } from './objectSelector'; import { initBootstrap } from './bs'; function initDepedencies(): void { - for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) { + for (const init of [initButtons, initSelects, initObjectSelector, initBootstrap]) { init(); } } diff --git a/netbox/project-static/src/index.ts b/netbox/project-static/src/index.ts index 77c1e85ce..7834ae927 100644 --- a/netbox/project-static/src/index.ts +++ b/netbox/project-static/src/index.ts @@ -1,4 +1,5 @@ import '@popperjs/core'; import 'bootstrap'; import 'htmx.org'; +import 'tom-select'; import './netbox'; diff --git a/netbox/project-static/src/netbox.ts b/netbox/project-static/src/netbox.ts index ed294e655..8c274c64b 100644 --- a/netbox/project-static/src/netbox.ts +++ b/netbox/project-static/src/netbox.ts @@ -1,7 +1,7 @@ import { initForms } from './forms'; import { initBootstrap } from './bs'; import { initQuickSearch } from './search'; -import { initSelect } from './select'; +import { initSelects } from './select'; import { initButtons } from './buttons'; import { initColorMode } from './colorMode'; import { initMessages } from './messages'; @@ -22,7 +22,7 @@ function initDocument(): void { initMessages, initForms, initQuickSearch, - initSelect, + initSelects, initDateSelector, initButtons, initClipboard, diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts deleted file mode 100644 index 279340c12..000000000 --- a/netbox/project-static/src/select/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/api/index.ts b/netbox/project-static/src/select/api/index.ts deleted file mode 100644 index 3fef1ad6a..000000000 --- a/netbox/project-static/src/select/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/api/types.ts b/netbox/project-static/src/select/api/types.ts deleted file mode 100644 index 8179f4a3a..000000000 --- a/netbox/project-static/src/select/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/api/dynamicParams.ts b/netbox/project-static/src/select/classes/dynamicParamsMap.ts similarity index 96% rename from netbox/project-static/src/select/api/dynamicParams.ts rename to netbox/project-static/src/select/classes/dynamicParamsMap.ts index c31c1962b..cadf37e55 100644 --- a/netbox/project-static/src/select/api/dynamicParams.ts +++ b/netbox/project-static/src/select/classes/dynamicParamsMap.ts @@ -1,7 +1,7 @@ import { isTruthy } from '../../util'; -import { isDataDynamicParams } from './types'; +import { isDataDynamicParams } from '../types'; -import type { QueryParam } from './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 new file mode 100644 index 000000000..90bd64f1d --- /dev/null +++ b/netbox/project-static/src/select/classes/dynamicTomSelect.ts @@ -0,0 +1,305 @@ +import { RecursivePartial, TomInput, TomOption, TomSettings } from 'tom-select/dist/types/types'; +import { addClasses } from 'tom-select/src/vanilla' +import queryString from 'query-string'; +import TomSelect from 'tom-select'; +import type { Stringifiable } from 'query-string'; +import { DynamicParamsMap } from './dynamicParamsMap'; + +// Transitional +import { QueryFilter, PathFilter } from '../types' +import { getElement, replaceAll } from '../../util'; + + +// Extends TomSelect to provide enhanced fetching of options via the REST API +export class DynamicTomSelect extends TomSelect { + + public readonly nullOption: Nullable = null; + + // Transitional code from APISelect + private readonly queryParams: QueryFilter = new Map(); + private readonly staticParams: QueryFilter = new Map(); + private readonly dynamicParams: DynamicParamsMap = new DynamicParamsMap(); + private readonly pathValues: PathFilter = new Map(); + + /** + * Overrides + */ + + constructor( input_arg: string|TomInput, user_settings: RecursivePartial ) { + super(input_arg, user_settings); + + // Glean the REST API endpoint URL from the elements which are populated via a REST API call +export function initDynamicSelects(): void { + for (const select of getElements('select.api-select')) { + new DynamicTomSelect(select, { + ...config, + valueField: VALUE_FIELD, + labelField: LABEL_FIELD, + maxOptions: MAX_OPTIONS, + + // Disable local search (search is performed on the backend) + searchField: [], + + // Reference the disabled-indicator attr on the elements with statically-defined options +export function initStaticSelects(): void { + for (const select of getElements( + 'select:not(.api-select):not(.color-select)', + )) { + new TomSelect(select, { + ...config, + }); + } +} + +// Initialize color selection fields +export function initColorSelects(): void { + for (const select of getElements('select.color-select')) { + new TomSelect(select, { + ...config, + render: { + option: function (item: TomOption, escape: typeof escape_html) { + return `
${escape(item.text)}
`; + }, + }, + }); } } 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/util.ts b/netbox/project-static/src/select/util.ts deleted file mode 100644 index daf7839dc..000000000 --- a/netbox/project-static/src/select/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); -} diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index 17c6d88c9..0fece199b 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -1,7 +1,8 @@ @import 'variables'; -// Tabler +// Tabler & vendors @import '../node_modules/@tabler/core/src/scss/_core.scss'; +@import '../node_modules/@tabler/core/src/scss/vendor/tom-select'; // Overrides of external libraries @import 'overrides/slim-select'; diff --git a/netbox/project-static/tsconfig.json b/netbox/project-static/tsconfig.json index 69b4bb664..4adcca950 100644 --- a/netbox/project-static/tsconfig.json +++ b/netbox/project-static/tsconfig.json @@ -3,7 +3,8 @@ "forceConsistentCasingInFileNames": true, "allowSyntheticDefaultImports": true, "moduleResolution": "node", - "noUnusedParameters": true, + // tom-select v2.3.1 raises several TS6133 errors with noUnusedParameters + "noUnusedParameters": false, "esModuleInterop": true, "isolatedModules": true, "noUnusedLocals": true, diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index 14c62ad03..9aa66d10c 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@esbuild/linux-loong64@0.14.54": + version "0.14.54" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028" + integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw== + "@eslint/eslintrc@^1.3.2": version "1.3.2" resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.2.tgz" @@ -67,7 +72,7 @@ "@nodelib/fs.stat" "2.0.5" run-parallel "^1.1.9" -"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": version "2.0.5" resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== @@ -80,6 +85,18 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@orchidjs/sifter@^1.0.3": + version "1.0.3" + resolved "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.0.3.tgz" + integrity sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g== + dependencies: + "@orchidjs/unicode-variants" "^1.0.4" + +"@orchidjs/unicode-variants@^1.0.4": + version "1.0.4" + resolved "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz" + integrity sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ== + "@pkgr/utils@^2.3.1": version "2.3.1" resolved "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz" @@ -92,16 +109,11 @@ tiny-glob "^0.2.9" tslib "^2.4.0" -"@popperjs/core@^2.11.8": +"@popperjs/core@^2.11.6", "@popperjs/core@^2.11.8", "@popperjs/core@^2.9.2": version "2.11.8" - resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== -"@popperjs/core@^2.9.2": - version "2.11.6" - resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz" - integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== - "@tabler/core@1.0.0-beta20": version "1.0.0-beta20" resolved "https://registry.npmjs.org/@tabler/core/-/core-1.0.0-beta20.tgz" @@ -138,6 +150,13 @@ resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/node@^20.11.16": + version "20.11.16" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.16.tgz#4411f79411514eb8e2926f036c86c9f0e4ec6708" + integrity sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ== + dependencies: + undici-types "~5.26.4" + "@typescript-eslint/eslint-plugin@^5.39.0": version "5.39.0" resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.39.0.tgz" @@ -152,7 +171,7 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/parser@^5.0.0", "@typescript-eslint/parser@^5.39.0": +"@typescript-eslint/parser@^5.39.0": version "5.39.0" resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.39.0.tgz" integrity sha512-PhxLjrZnHShe431sBAGHaNe6BDdxAASDySgsBCGxcBecVCi8NQWxQZMcizNA4g0pN51bBAn/FUfkWG3SDVcGlA== @@ -223,7 +242,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.8.0: +acorn@^8.8.0: version "8.8.0" resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz" integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== @@ -577,6 +596,71 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" +esbuild-android-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be" + integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ== + +esbuild-android-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz#3fc3ff0bab76fe35dd237476b5d2b32bb20a3d44" + integrity sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg== + +esbuild-android-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771" + integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg== + +esbuild-darwin-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz#8e9169c16baf444eacec60d09b24d11b255a8e72" + integrity sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ== + +esbuild-darwin-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25" + integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug== + +esbuild-darwin-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.15.tgz#1b07f893b632114f805e188ddfca41b2b778229a" + integrity sha512-i1FZssTVxUqNlJ6cBTj5YQj4imWy3m49RZRnHhLpefFIh0To05ow9DTrXROTE1urGTQCloFUXTX8QfGJy1P8dQ== + +esbuild-darwin-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73" + integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw== + +esbuild-freebsd-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz#0b8b7eca1690c8ec94c75680c38c07269c1f4a85" + integrity sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA== + +esbuild-freebsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d" + integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg== + +esbuild-freebsd-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz#2e1a6c696bfdcd20a99578b76350b41db1934e52" + integrity sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ== + +esbuild-freebsd-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48" + integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q== + +esbuild-linux-32@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz#6fd39f36fc66dd45b6b5f515728c7bbebc342a69" + integrity sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g== + +esbuild-linux-32@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5" + integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw== + esbuild-linux-64@0.13.15: version "0.13.15" resolved "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz" @@ -587,6 +671,76 @@ esbuild-linux-64@0.14.54: resolved "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz" integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg== +esbuild-linux-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz#3891aa3704ec579a1b92d2a586122e5b6a2bfba1" + integrity sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA== + +esbuild-linux-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b" + integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig== + +esbuild-linux-arm@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz#8a00e99e6a0c6c9a6b7f334841364d8a2b4aecfe" + integrity sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA== + +esbuild-linux-arm@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59" + integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw== + +esbuild-linux-mips64le@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz#36b07cc47c3d21e48db3bb1f4d9ef8f46aead4f7" + integrity sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg== + +esbuild-linux-mips64le@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34" + integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw== + +esbuild-linux-ppc64le@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz#f7e6bba40b9a11eb9dcae5b01550ea04670edad2" + integrity sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ== + +esbuild-linux-ppc64le@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e" + integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ== + +esbuild-linux-riscv64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8" + integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg== + +esbuild-linux-s390x@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6" + integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA== + +esbuild-netbsd-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.15.tgz#a2fedc549c2b629d580a732d840712b08d440038" + integrity sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w== + +esbuild-netbsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81" + integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w== + +esbuild-openbsd-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz#b22c0e5806d3a1fbf0325872037f885306b05cd7" + integrity sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g== + +esbuild-openbsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b" + integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw== + esbuild-sass-plugin@^2.3.3: version "2.3.3" resolved "https://registry.npmjs.org/esbuild-sass-plugin/-/esbuild-sass-plugin-2.3.3.tgz" @@ -596,6 +750,46 @@ esbuild-sass-plugin@^2.3.3: resolve "^1.22.1" sass "^1.49.0" +esbuild-sunos-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz#d0b6454a88375ee8d3964daeff55c85c91c7cef4" + integrity sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw== + +esbuild-sunos-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da" + integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw== + +esbuild-windows-32@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz#c96d0b9bbb52f3303322582ef8e4847c5ad375a7" + integrity sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw== + +esbuild-windows-32@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31" + integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w== + +esbuild-windows-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz#1f79cb9b1e1bb02fb25cd414cb90d4ea2892c294" + integrity sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ== + +esbuild-windows-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4" + integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ== + +esbuild-windows-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz#482173070810df22a752c686509c370c3be3b3c3" + integrity sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA== + +esbuild-windows-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982" + integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg== + esbuild@^0.13.15: version "0.13.15" resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.13.15.tgz" @@ -689,7 +883,7 @@ eslint-module-utils@^2.7.3: dependencies: debug "^3.2.7" -eslint-plugin-import@*, eslint-plugin-import@^2.26.0: +eslint-plugin-import@^2.26.0: version "2.26.0" resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz" integrity sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA== @@ -748,7 +942,7 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@*, "eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8", "eslint@^6.0.0 || ^7.0.0 || ^8.0.0", eslint@^8.24.0, eslint@>=5, eslint@>=7.0.0, eslint@>=7.28.0: +eslint@^8.24.0: version "8.24.0" resolved "https://registry.npmjs.org/eslint/-/eslint-8.24.0.tgz" integrity sha512-dWFaPhGhTAiPcCgm3f6LI2MBWbogMnTJzFBbhXVRQDJPkr9pGZvVjlVfXd+vyDcWPA2Ic9L2AXPIQM0+vk/cSQ== @@ -909,7 +1103,7 @@ flat-cache@^3.0.4: flatted "^3.1.0" rimraf "^3.0.2" -flatpickr@^4.6.13, flatpickr@4.6.13: +flatpickr@4.6.13: version "4.6.13" resolved "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz" integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw== @@ -924,6 +1118,11 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz" @@ -1089,7 +1288,7 @@ graphql-language-service@^5.0.6: nullthrows "^1.0.0" vscode-languageserver-types "^3.15.1" -"graphql@^15.5.0 || ^16.0.0", "graphql@>= v14.5.0 <= 15.5.0", graphql@>=0.10.0: +"graphql@>= v14.5.0 <= 15.5.0": version "15.5.0" resolved "https://registry.npmjs.org/graphql/-/graphql-15.5.0.tgz" integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA== @@ -1121,12 +1320,7 @@ has-property-descriptors@^1.0.0: dependencies: get-intrinsic "^1.1.1" -has-symbols@^1.0.1: - version "1.0.2" - resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz" - integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== - -has-symbols@^1.0.2: +has-symbols@^1.0.1, has-symbols@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz" integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== @@ -1268,14 +1462,7 @@ is-extglob@^2.1.1: resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= -is-glob@^4.0.0: - version "4.0.1" - resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz" - integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== - dependencies: - is-extglob "^2.1.1" - -is-glob@^4.0.1: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz" integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== @@ -1289,13 +1476,6 @@ is-glob@^4.0.3: dependencies: is-extglob "^2.1.1" -is-glob@~4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz" - integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== - dependencies: - is-extglob "^2.1.1" - is-negative-zero@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz" @@ -1417,11 +1597,6 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" -just-debounce-it@^3.1.1: - version "3.1.1" - resolved "https://registry.npmjs.org/just-debounce-it/-/just-debounce-it-3.1.1.tgz" - integrity sha512-oPsuRyWp99LJaQ4KXC3A42tQNqkRTcPy0A8BCkRZ5cPCgsx81upB2KUrmHZvDUNhnCDKe7MshfTuWFQB9iXwDg== - levn@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz" @@ -1521,11 +1696,6 @@ minimist@^1.2.6: resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== -ms@^2.1.1: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -1536,22 +1706,16 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= -"netbox-graphiql@file:/home/jstretch/projects/netbox/netbox/project-static/netbox-graphiql": - version "0.1.0" - resolved "file:netbox-graphiql" - dependencies: - graphiql "1.8.9" - graphql ">= v14.5.0 <= 15.5.0" - react "17.0.2" - react-dom "17.0.2" - subscriptions-transport-ws "0.9.18" - whatwg-fetch "3.6.2" - normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz" @@ -1697,7 +1861,7 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" -prettier@^2.7.1, prettier@>=2.0.0: +prettier@^2.7.1: version "2.7.1" resolved "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz" integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== @@ -1722,7 +1886,7 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -"react-dom@^16.8.0 || ^17.0.0 || ^18.0.0", react-dom@17.0.2: +react-dom@17.0.2: version "17.0.2" resolved "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz" integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== @@ -1731,7 +1895,7 @@ queue-microtask@^1.2.2: object-assign "^4.1.1" scheduler "^0.20.2" -"react@^16.8.0 || ^17.0.0 || ^18.0.0", react@17.0.2: +react@17.0.2: version "17.0.2" resolved "https://registry.npmjs.org/react/-/react-17.0.2.tgz" integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== @@ -1878,11 +2042,6 @@ slash@^4.0.0: resolved "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz" integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== -slim-select@^1.27.1: - version "1.27.1" - resolved "https://registry.npmjs.org/slim-select/-/slim-select-1.27.1.tgz" - integrity sha512-LvJ02cKKk6/jSHIcQv7dZwkQSXHLCVQR3v3lo8RJUssUUcmKPkpBmTpQ8au8KSMkxwca9+yeg+dO0iHAaVr5Aw== - "source-map-js@>=0.6.2 <2.0.0": version "1.0.2" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz" @@ -2004,6 +2163,14 @@ toggle-selection@^1.0.6: resolved "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz" integrity sha1-bkWxJj8gF/oKzH2J14sVuL932jI= +tom-select@^2.3.1: + version "2.3.1" + resolved "https://registry.npmjs.org/tom-select/-/tom-select-2.3.1.tgz" + integrity sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg== + dependencies: + "@orchidjs/sifter" "^1.0.3" + "@orchidjs/unicode-variants" "^1.0.4" + tsconfig-paths@^3.14.1: version "3.14.1" resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz" @@ -2053,7 +2220,7 @@ typeface-roboto-mono@^1.1.13: resolved "https://registry.npmjs.org/typeface-roboto-mono/-/typeface-roboto-mono-1.1.13.tgz" integrity sha512-pnzDc70b7ywJHin/BUFL7HZX8DyOTBLT2qxlJ92eH1UJOFcENIBXa9IZrxsJX/gEKjbEDKhW5vz/TKRBNk/ufQ== -"typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta", typescript@~4.8.4: +typescript@~4.8.4: version "4.8.4" resolved "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz" integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ== @@ -2073,6 +2240,11 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + uri-js@^4.2.2: version "4.4.1" resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz" diff --git a/netbox/templates/django/forms/widgets/select.html b/netbox/templates/django/forms/widgets/select.html index 0e87394e3..4688a9e8e 100644 --- a/netbox/templates/django/forms/widgets/select.html +++ b/netbox/templates/django/forms/widgets/select.html @@ -1,4 +1,4 @@ -{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %} {% endif %}{% for option in group_choices %} {% include option.template_name with widget=option %}{% endfor %}{% if group_name %} {% endif %}{% endfor %} diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 11874e929..d759dd2ac 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -186,8 +186,7 @@ class UserForm(forms.ModelForm): object_permissions = DynamicModelMultipleChoiceField( required=False, label=_('Permissions'), - queryset=ObjectPermission.objects.all(), - to_field_name='pk', + queryset=ObjectPermission.objects.all() ) fieldsets = ( @@ -244,8 +243,7 @@ class GroupForm(forms.ModelForm): object_permissions = DynamicModelMultipleChoiceField( required=False, label=_('Permissions'), - queryset=ObjectPermission.objects.all(), - to_field_name='pk', + queryset=ObjectPermission.objects.all() ) fieldsets = ( diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py index 00a1f823e..eb0dc9b59 100644 --- a/netbox/utilities/forms/fields/dynamic.py +++ b/netbox/utilities/forms/fields/dynamic.py @@ -64,8 +64,6 @@ class DynamicModelChoiceMixin: null_option: The string used to represent a null selection (if any) disabled_indicator: The name of the field which, if populated, will disable selection of the choice (optional) - fetch_trigger: The event type which will cause the select element to - fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional) selector: Include an advanced object selection widget to assist the user in identifying the desired object """ filter = django_filters.ModelChoiceFilter @@ -79,8 +77,6 @@ class DynamicModelChoiceMixin: initial_params=None, null_option=None, disabled_indicator=None, - fetch_trigger=None, - empty_label=None, selector=False, **kwargs ): @@ -89,24 +85,12 @@ class DynamicModelChoiceMixin: self.initial_params = initial_params or {} self.null_option = null_option self.disabled_indicator = disabled_indicator - self.fetch_trigger = fetch_trigger self.selector = selector - # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference - # by widget_attrs() - self.to_field_name = kwargs.get('to_field_name') - self.empty_option = empty_label or "" - super().__init__(queryset, **kwargs) def widget_attrs(self, widget): - attrs = { - 'data-empty-option': self.empty_option - } - - # Set value-field attribute if the field specifies to_field_name - if self.to_field_name: - attrs['value-field'] = self.to_field_name + attrs = {} # Set the string used to represent a null option if self.null_option is not None: @@ -116,10 +100,6 @@ class DynamicModelChoiceMixin: if self.disabled_indicator is not None: attrs['disabled-indicator'] = self.disabled_indicator - # Set the fetch trigger, if any. - if self.fetch_trigger is not None: - attrs['data-fetch-trigger'] = self.fetch_trigger - # Attach any static query parameters if (len(self.query_params) > 0): widget.add_query_params(self.query_params) diff --git a/netbox/utilities/forms/widgets/apiselect.py b/netbox/utilities/forms/widgets/apiselect.py index e4b02cb1d..ac891c0bb 100644 --- a/netbox/utilities/forms/widgets/apiselect.py +++ b/netbox/utilities/forms/widgets/apiselect.py @@ -24,7 +24,7 @@ class APISelect(forms.Select): def __init__(self, api_url=None, full=False, *args, **kwargs): super().__init__(*args, **kwargs) - self.attrs['class'] = 'netbox-api-select' + self.attrs['class'] = 'api-select' self.dynamic_params: Dict[str, List[str]] = {} self.static_params: Dict[str, List[str]] = {} @@ -153,8 +153,4 @@ class APISelect(forms.Select): class APISelectMultiple(APISelect, forms.SelectMultiple): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.attrs['data-multiple'] = 1 + pass diff --git a/netbox/utilities/forms/widgets/select.py b/netbox/utilities/forms/widgets/select.py index 2e2d829cd..37443c056 100644 --- a/netbox/utilities/forms/widgets/select.py +++ b/netbox/utilities/forms/widgets/select.py @@ -25,7 +25,6 @@ class BulkEditNullBooleanSelect(forms.NullBooleanSelect): ('2', 'Yes'), ('3', 'No'), ) - self.attrs['class'] = 'netbox-static-select' class ColorSelect(forms.Select): @@ -37,7 +36,7 @@ class ColorSelect(forms.Select): def __init__(self, *args, **kwargs): kwargs['choices'] = add_blank_choice(ColorChoices) super().__init__(*args, **kwargs) - self.attrs['class'] = 'netbox-color-select' + self.attrs['class'] = 'color-select' class HTMXSelect(forms.Select): diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index f936de88c..9e5e17a09 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -423,8 +423,7 @@ class L2VPNTerminationForm(NetBoxModelForm): queryset=L2VPN.objects.all(), required=True, query_params={}, - label=_('L2VPN'), - fetch_trigger='open' + label=_('L2VPN') ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(),