mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 12:06:53 -06:00
fix issue where select fields with a pre-populated value were reset when forms were submitted, due to having the disabled attribute set.
This commit is contained in:
parent
c3c79d3715
commit
21db209f47
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -43,25 +43,11 @@ function initSpeedSelector(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* based on the field's validity.
|
||||
*/
|
||||
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]');
|
||||
|
||||
function callback(event: Event): void {
|
||||
function handleFormSubmit(event: Event, form: HTMLFormElement): void {
|
||||
// Track the names of each invalid field.
|
||||
const invalids = new Set<string>();
|
||||
|
||||
for (const el of elements) {
|
||||
const element = (el as unknown) as FormControls;
|
||||
|
||||
for (const element of form.querySelectorAll<FormControls>('*[name]')) {
|
||||
if (!element.validity.valid) {
|
||||
invalids.add(element.name);
|
||||
|
||||
@ -87,16 +73,28 @@ function initFormElements() {
|
||||
|
||||
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;
|
||||
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
|
||||
* based on the field's validity.
|
||||
*/
|
||||
function initFormElements() {
|
||||
for (const form of getElements('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<HTMLButtonElement>('button[type=submit]');
|
||||
|
||||
for (const submitter of submitters) {
|
||||
// Add the event listener to each submitter.
|
||||
submitter.addEventListener('click', callback);
|
||||
submitter.addEventListener('click', event => handleFormSubmit(event, form));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<T extends Record<string, string>> extends HTMLSelectElement {
|
||||
dataset: T;
|
||||
}
|
||||
type CustomSelect<T extends Record<string, string>> = HTMLSelectElement & T;
|
||||
|
||||
function isCustomSelect(el: HTMLSelectElement): el is CustomSelect<WithUrl> {
|
||||
return typeof el?.dataset?.url === 'string';
|
||||
function hasUrl(el: HTMLSelectElement): el is CustomSelect<WithUrl> {
|
||||
const value = el.getAttribute('data-url');
|
||||
return typeof value === 'string' && value !== '';
|
||||
}
|
||||
|
||||
function hasExclusions(el: HTMLSelectElement): el is CustomSelect<WithExclude> {
|
||||
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,10 +66,10 @@ 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 => {
|
||||
const data = await getApiData(url);
|
||||
if (hasError(data)) {
|
||||
if (isApiError(data)) {
|
||||
createToast('danger', data.exception, data.error).show();
|
||||
@ -88,6 +86,7 @@ async function getOptions(
|
||||
const text = getDisplayName(result, select);
|
||||
const data = {} as Record<string, string>;
|
||||
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)) {
|
||||
@ -95,13 +94,16 @@ async function getOptions(
|
||||
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.
|
||||
@ -109,9 +111,12 @@ async function getOptions(
|
||||
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;
|
||||
// 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 = {
|
||||
@ -126,7 +131,6 @@ async function getOptions(
|
||||
options.push(option);
|
||||
}
|
||||
return options;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -175,27 +179,27 @@ function getDisplayName(result: APIObjectBase, select: HTMLSelectElement): strin
|
||||
*/
|
||||
export function initApiSelect() {
|
||||
for (const select of getElements<HTMLSelectElement>('.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<string, string>;
|
||||
// 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: `<i class="bi bi-x-circle" style="color:currentColor;"></i>`,
|
||||
deselectLabel: `<i class="mdi mdi-close-circle" style="color:currentColor;"></i>`,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A non-empty value indicates a static query parameter.
|
||||
/**
|
||||
* 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<HTMLSelectElement>;
|
||||
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) {
|
||||
// Check the query param key to see if we should modify it.
|
||||
if (key.match(pattern)) {
|
||||
key = key.replaceAll(pattern, replacement);
|
||||
if (id.match(pattern)) {
|
||||
key = id.replaceAll(pattern, replacement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Add post-replaced key/value pairs to the query object.
|
||||
if (isTruthy(value)) {
|
||||
query[key] = value;
|
||||
}
|
||||
if (isTruthy(element.value)) {
|
||||
// Add the dependency's value to the URL query.
|
||||
query[key] = element.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);
|
||||
|
@ -34,7 +34,7 @@ export function initColorSelect(): void {
|
||||
select,
|
||||
allowDeselect: true,
|
||||
// Inherit the calculated color on the deselect icon.
|
||||
deselectLabel: `<i class="bi bi-x-circle" style="color: currentColor;"></i>`,
|
||||
deselectLabel: `<i class="mdi mdi-close-circle" style="color: currentColor;"></i>`,
|
||||
});
|
||||
|
||||
// Style the select container to match any pre-selectd options.
|
||||
|
@ -14,7 +14,7 @@ export function initStaticSelect() {
|
||||
const instance = new SlimSelect({
|
||||
select,
|
||||
allowDeselect: true,
|
||||
deselectLabel: `<i class="bi bi-x-circle"></i>`,
|
||||
deselectLabel: `<i class="mdi mdi-close-circle"></i>`,
|
||||
placeholder,
|
||||
});
|
||||
|
||||
|
@ -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<T extends HTMLElement>(element: T): Map<string, st
|
||||
}
|
||||
return filterMap;
|
||||
}
|
||||
|
||||
function* getAllDependencyIds<E extends HTMLElement>(element: Nullable<E>): Generator<string> {
|
||||
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<E extends HTMLElement>(element: Nullable<E>): string[] {
|
||||
const ids = new Set<string>(getAllDependencyIds(element));
|
||||
return Array.from(ids).map(i => i.replaceAll('_id', ''));
|
||||
}
|
||||
|
@ -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 %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#docs_modal"
|
||||
title="Help"
|
||||
>
|
||||
{% if settings.DOCS_ROOT %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#docs_modal" title="Help">
|
||||
<i class="mdi mdi-help-circle"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% 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 %}
|
||||
<div class="field-group">
|
||||
<h4 class="mb-3">{{ group }}</h4>
|
||||
|
Loading…
Reference in New Issue
Block a user