diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 992a49015..75f297da2 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 0e2a7e892..ebac5eddc 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/forms.ts b/netbox/project-static/src/forms.ts index 3a340c044..92728ef37 100644 --- a/netbox/project-static/src/forms.ts +++ b/netbox/project-static/src/forms.ts @@ -43,6 +43,44 @@ function initSpeedSelector(): void { } } +function handleFormSubmit(event: Event, form: HTMLFormElement): void { + // Track the names of each invalid field. + const invalids = new Set(); + + for (const element of form.querySelectorAll('*[name]')) { + if (!element.validity.valid) { + invalids.add(element.name); + + // If the field is invalid, but contains the .is-valid class, remove it. + if (element.classList.contains('is-valid')) { + element.classList.remove('is-valid'); + } + // If the field is invalid, but doesn't contain the .is-invalid class, add it. + if (!element.classList.contains('is-invalid')) { + element.classList.add('is-invalid'); + } + } else { + // If the field is valid, but contains the .is-invalid class, remove it. + if (element.classList.contains('is-invalid')) { + element.classList.remove('is-invalid'); + } + // If the field is valid, but doesn't contain the .is-valid class, add it. + if (!element.classList.contains('is-valid')) { + element.classList.add('is-valid'); + } + } + } + + if (invalids.size !== 0) { + // If there are invalid fields, pick the first field and scroll to it. + const firstInvalid = form.elements.namedItem(Array.from(invalids)[0]) as Element; + scrollTo(firstInvalid); + + // If the form has invalid fields, don't submit it. + event.preventDefault(); + } +} + /** * Attach an event listener to each form's submitter (button[type=submit]). When called, the * callback checks the validity of each form field and adds the appropriate Bootstrap CSS class @@ -50,53 +88,13 @@ function initSpeedSelector(): void { */ function initFormElements() { for (const form of getElements('form')) { - const { elements } = form; // Find each of the form's submitters. Most object edit forms have a "Create" and // a "Create & Add", so we need to add a listener to both. - const submitters = form.querySelectorAll('button[type=submit]'); + const submitters = form.querySelectorAll('button[type=submit]'); - function callback(event: Event): void { - // Track the names of each invalid field. - const invalids = new Set(); - - for (const el of elements) { - const element = (el as unknown) as FormControls; - - if (!element.validity.valid) { - invalids.add(element.name); - - // If the field is invalid, but contains the .is-valid class, remove it. - if (element.classList.contains('is-valid')) { - element.classList.remove('is-valid'); - } - // If the field is invalid, but doesn't contain the .is-invalid class, add it. - if (!element.classList.contains('is-invalid')) { - element.classList.add('is-invalid'); - } - } else { - // If the field is valid, but contains the .is-invalid class, remove it. - if (element.classList.contains('is-invalid')) { - element.classList.remove('is-invalid'); - } - // If the field is valid, but doesn't contain the .is-valid class, add it. - if (!element.classList.contains('is-valid')) { - element.classList.add('is-valid'); - } - } - } - - if (invalids.size !== 0) { - // If there are invalid fields, pick the first field and scroll to it. - const firstInvalid = elements.namedItem(Array.from(invalids)[0]) as Element; - scrollTo(firstInvalid); - - // If the form has invalid fields, don't submit it. - event.preventDefault(); - } - } for (const submitter of submitters) { // Add the event listener to each submitter. - submitter.addEventListener('click', callback); + submitter.addEventListener('click', event => handleFormSubmit(event, form)); } } } diff --git a/netbox/project-static/src/select/api.ts b/netbox/project-static/src/select/api.ts index 9db037c05..007e0814a 100644 --- a/netbox/project-static/src/select/api.ts +++ b/netbox/project-static/src/select/api.ts @@ -2,12 +2,12 @@ import SlimSelect from 'slim-select'; import queryString from 'query-string'; import { getApiData, isApiError, getElements, isTruthy, hasError } from '../util'; import { createToast } from '../bs'; -import { setOptionStyles, getFilteredBy, toggle } from './util'; +import { setOptionStyles, toggle, getDependencyIds } from './util'; import type { Option } from 'slim-select/dist/data'; type WithUrl = { - url: string; + 'data-url': string; }; type WithExclude = { @@ -16,18 +16,16 @@ type WithExclude = { type ReplaceTuple = [RegExp, string]; -interface CustomSelect> extends HTMLSelectElement { - dataset: T; -} +type CustomSelect> = HTMLSelectElement & T; -function isCustomSelect(el: HTMLSelectElement): el is CustomSelect { - return typeof el?.dataset?.url === 'string'; +function hasUrl(el: HTMLSelectElement): el is CustomSelect { + const value = el.getAttribute('data-url'); + return typeof value === 'string' && value !== ''; } function hasExclusions(el: HTMLSelectElement): el is CustomSelect { - return ( - typeof el?.dataset?.queryParamExclude === 'string' && el?.dataset?.queryParamExclude !== '' - ); + const exclude = el.getAttribute('data-query-param-exclude'); + return typeof exclude === 'string' && exclude !== ''; } const DISABLED_ATTRIBUTES = ['occupied'] as string[]; @@ -68,65 +66,71 @@ async function getOptions( // existing object. When we fetch options from the API later, we can set any of the options // contained in this array to `selected`. const selectOptions = Array.from(select.options) - .filter(option => option.value !== '') - .map(option => option.value); + .map(option => option.getAttribute('value')) + .filter(isTruthy); - return getApiData(url).then(data => { - if (hasError(data)) { - if (isApiError(data)) { - createToast('danger', data.exception, data.error).show(); - return [PLACEHOLDER]; - } - createToast('danger', `Error Fetching Options for field ${select.name}`, data.error).show(); + const data = await getApiData(url); + if (hasError(data)) { + if (isApiError(data)) { + createToast('danger', data.exception, data.error).show(); return [PLACEHOLDER]; } + createToast('danger', `Error Fetching Options for field ${select.name}`, data.error).show(); + return [PLACEHOLDER]; + } - const { results } = data; - const options = [PLACEHOLDER] as Option[]; + const { results } = data; + const options = [PLACEHOLDER] as Option[]; - for (const result of results) { - const text = getDisplayName(result, select); - const data = {} as Record; - const value = result.id.toString(); + for (const result of results) { + const text = getDisplayName(result, select); + const data = {} as Record; + const value = result.id.toString(); + 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 = k.replaceAll('_', '-'); - data[key] = String(v); + // Set any primitive k/v pairs as data attributes on each option. + for (const [k, v] of Object.entries(result)) { + if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) { + const key = k.replaceAll('_', '-'); + data[key] = String(v); + } + // Set option to disabled if the result contains a matching key and is truthy. + if (DISABLED_ATTRIBUTES.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; } } - - let style, selected, disabled; - - // Set pre-selected options. - if (selectOptions.includes(value)) { - selected = true; - } - - // Set option to disabled if it is contained within the disabled array. - if (selectOptions.some(option => disabledOptions.includes(option))) { - disabled = true; - } - - // Set option to disabled if the result contains a matching key and is truthy. - if (DISABLED_ATTRIBUTES.some(key => Object.keys(result).includes(key) && result[key])) { - disabled = true; - } - - const option = { - value, - text, - data, - style, - selected, - disabled, - } as Option; - - options.push(option); } - return options; - }); + + // Set option to disabled if it is contained within the disabled array. + if (selectOptions.some(option => disabledOptions.includes(option))) { + disabled = true; + } + + // Set pre-selected options. + if (selectOptions.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.push(option); + } + return options; } /** @@ -175,27 +179,27 @@ function getDisplayName(result: APIObjectBase, select: HTMLSelectElement): strin */ export function initApiSelect() { for (const select of getElements('.netbox-select2-api')) { - const filterMap = getFilteredBy(select); + const dependencies = getDependencyIds(select); // Initialize an event, so other elements relying on this element can subscribe to this // element's value. const event = new Event(`netbox.select.onload.${select.name}`); // Query Parameters - will have attributes added below. const query = {} as Record; - // List of OTHER elements THIS element relies on for query filtering. - const groupBy = [] as HTMLSelectElement[]; - if (isCustomSelect(select)) { + if (hasUrl(select)) { // Store the original URL, so it can be referred back to as filter-by elements change. - const originalUrl = JSON.parse(JSON.stringify(select.dataset.url)) as string; - // Unpack the original URL with the intent of reassigning it as context updates. - let { url } = select.dataset; + // const originalUrl = select.getAttribute('data-url') as string; + // Get the original URL with the intent of reassigning it as context updates. + let url = select.getAttribute('data-url') ?? ''; const placeholder = getPlaceholder(select); let disabledOptions = [] as string[]; if (hasExclusions(select)) { try { - const exclusions = JSON.parse(select.dataset.queryParamExclude) as string[]; + const exclusions = JSON.parse( + select.getAttribute('data-query-param-exclude') ?? '[]', + ) as string[]; disabledOptions = [...disabledOptions, ...exclusions]; } catch (err) { console.warn( @@ -207,7 +211,7 @@ export function initApiSelect() { const instance = new SlimSelect({ select, allowDeselect: true, - deselectLabel: ``, + deselectLabel: ``, placeholder, onChange() { const element = instance.slim.container ?? null; @@ -233,42 +237,52 @@ export function initApiSelect() { instance.slim.container.classList.remove(className); } - for (let [key, value] of filterMap) { - if (value === '') { - // An empty value is set if the key contains a `$`, indicating reliance on another field. - const elem = document.querySelector(`[name=${key}]`) as HTMLSelectElement; - if (elem !== null) { - groupBy.push(elem); - if (elem.value !== '') { - // If the element's form value exists, add it to the map. - value = elem.value; - filterMap.set(key, elem.value); + /** + * Update an element's API URL based on the value of another element upon which this element + * relies. + * + * @param id DOM ID of the other element. + */ + function updateQuery(id: string) { + let key = id; + // Find the element dependency. + const element = document.getElementById(`id_${id}`) as Nullable; + if (element !== null) { + if (element.value !== '') { + // If the dependency has a value, parse the dependency's name (form key) for any + // required replacements. + for (const [pattern, replacement] of REPLACE_PATTERNS) { + if (id.match(pattern)) { + key = id.replaceAll(pattern, replacement); + break; + } + } + // 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/`. + if (url.includes(`{{`)) { + for (const test of url.matchAll(new RegExp(`({{(${id}|${key})}})`, 'g'))) { + // The template tag may contain the original element name or the post-parsed value. + url = url.replaceAll(test[1], element.value); + } + // Set the DOM attribute to reflect the change. + select.setAttribute('data-url', url); } } - } - - // A non-empty value indicates a static query parameter. - for (const [pattern, replacement] of REPLACE_PATTERNS) { - // Check the query param key to see if we should modify it. - if (key.match(pattern)) { - key = key.replaceAll(pattern, replacement); - break; + if (isTruthy(element.value)) { + // Add the dependency's value to the URL query. + query[key] = element.value; } } - - if (url.includes(`{{`) && value !== '') { - // If the URL contains a Django/Jinja template variable, we need to replace the - // tag with the event's value. - url = url.replaceAll(new RegExp(`{{${key}}}`, 'g'), value); - select.setAttribute('data-url', url); - } - - // Add post-replaced key/value pairs to the query object. - if (isTruthy(value)) { - query[key] = value; - } + } + // Process each of the dependencies, updating this element's URL or other attributes as + // needed. + for (const dep of dependencies) { + updateQuery(dep); } + // Create a valid encoded URL with all query params. url = queryString.stringifyUrl({ url, query }); /** @@ -279,64 +293,35 @@ export function initApiSelect() { */ function handleEvent(event: Event) { const target = event.target as HTMLSelectElement; - - if (isTruthy(target.value)) { - let name = target.name; - - for (const [pattern, replacement] of REPLACE_PATTERNS) { - // Check the query param key to see if we should modify it. - if (name.match(pattern)) { - name = name.replaceAll(pattern, replacement); - break; - } - } - - if (url.includes(`{{`) && target.name && target.value) { - // If the URL (still) contains a Django/Jinja template variable, we need to replace - // the tag with the event's value. - url = url.replaceAll(new RegExp(`{{${target.name}}}`, 'g'), target.value); - select.setAttribute('data-url', url); - } - - if (filterMap.get(target.name) === '') { - // Update empty filter map values now that there is a value. - filterMap.set(target.name, target.value); - } - // Add post-replaced key/value pairs to the query object. - query[name] = target.value; - // Create a URL with all relevant query parameters. - url = queryString.stringifyUrl({ url, query }); - } else { - url = originalUrl; - } + // Update the element's URL after any changes to a dependency. + updateQuery(target.id); // Disable the element while data is loading. toggle('disable', instance); // Load new data. getOptions(url, select, disabledOptions) .then(data => instance.setData(data)) + .catch(console.error) .finally(() => { // Re-enable the element after data has loaded. toggle('enable', instance); // Inform any event listeners that data has updated. select.dispatchEvent(event); }); - // Stop event bubbling. - event.preventDefault(); } - for (const group of groupBy) { - // Re-fetch data when the group changes. - group.addEventListener('change', handleEvent); - - // Subscribe this instance (the child that relies on `group`) to any changes of the - // group's value, so we can re-render options. - select.addEventListener(`netbox.select.onload.${group.name}`, handleEvent); + for (const dep of dependencies) { + const element = document.getElementById(`id_${dep}`); + if (element !== null) { + element.addEventListener('change', handleEvent); + } + select.addEventListener(`netbox.select.onload.${dep}`, handleEvent); } // Load data. getOptions(url, select, disabledOptions) .then(options => instance.setData(options)) + .catch(console.error) .finally(() => { // Set option styles, if the field calls for it (color selectors). setOptionStyles(instance); diff --git a/netbox/project-static/src/select/color.ts b/netbox/project-static/src/select/color.ts index bde01406c..a413cebf4 100644 --- a/netbox/project-static/src/select/color.ts +++ b/netbox/project-static/src/select/color.ts @@ -34,7 +34,7 @@ export function initColorSelect(): void { select, allowDeselect: true, // Inherit the calculated color on the deselect icon. - deselectLabel: ``, + deselectLabel: ``, }); // Style the select container to match any pre-selectd options. diff --git a/netbox/project-static/src/select/static.ts b/netbox/project-static/src/select/static.ts index b82ef7ffe..d65d06d80 100644 --- a/netbox/project-static/src/select/static.ts +++ b/netbox/project-static/src/select/static.ts @@ -14,7 +14,7 @@ export function initStaticSelect() { const instance = new SlimSelect({ select, allowDeselect: true, - deselectLabel: ``, + deselectLabel: ``, placeholder, }); diff --git a/netbox/project-static/src/select/util.ts b/netbox/project-static/src/select/util.ts index 6e2c0381d..e12d79ef1 100644 --- a/netbox/project-static/src/select/util.ts +++ b/netbox/project-static/src/select/util.ts @@ -63,7 +63,7 @@ export function setOptionStyles(instance: SlimSelect): void { const fg = readableColor(bg); // Add a unique identifier to the style element. - style.dataset.netbox = id; + style.setAttribute('data-netbox', id); // Scope the CSS to apply both the list item and the selected item. style.innerHTML = ` @@ -155,3 +155,32 @@ export function getFilteredBy(element: T): Map(element: Nullable): Generator { + const keyPattern = new RegExp(/data-query-param-/g); + if (element !== null) { + for (const attr of element.attributes) { + if (attr.name.startsWith('data-query-param') && attr.name !== 'data-query-param-exclude') { + const dep = attr.name.replaceAll(keyPattern, ''); + yield dep; + for (const depNext of getAllDependencyIds(document.getElementById(`id_${dep}`))) { + yield depNext; + } + } else if (attr.name === 'data-url' && attr.value.includes(`{{`)) { + const value = attr.value.match(/\{\{(.+)\}\}/); + if (value !== null) { + const dep = value[1]; + yield dep; + for (const depNext of getAllDependencyIds(document.getElementById(`id_${dep}`))) { + yield depNext; + } + } + } + } + } +} + +export function getDependencyIds(element: Nullable): string[] { + const ids = new Set(getAllDependencyIds(element)); + return Array.from(ids).map(i => i.replaceAll('_id', '')); +} diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 6089ce123..3d0216ce0 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -3,17 +3,11 @@ {% block title %}{% if obj.pk %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}{% endblock %} {% block controls %} -{% if settings.DOCS_ROOT %} - -{% endif %} + {% if settings.DOCS_ROOT %} + + {% endif %} {% endblock %} {% block content %} @@ -26,7 +20,7 @@ {% block form %} {% if form.Meta.fieldsets %} - {# Render grouped fields accoring to Form #} + {# Render grouped fields according to Form #} {% for group, fields in form.Meta.fieldsets %}

{{ group }}