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:
checktheroads
2021-04-22 15:58:46 -07:00
parent c3c79d3715
commit 21db209f47
8 changed files with 207 additions and 201 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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<string>();
for (const element of form.querySelectorAll<FormControls>('*[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 * 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 * callback checks the validity of each form field and adds the appropriate Bootstrap CSS class
@@ -50,53 +88,13 @@ function initSpeedSelector(): void {
*/ */
function initFormElements() { function initFormElements() {
for (const form of getElements('form')) { for (const form of getElements('form')) {
const { elements } = form;
// Find each of the form's submitters. Most object edit forms have a "Create" and // 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. // a "Create & Add", so we need to add a listener to both.
const submitters = form.querySelectorAll('button[type=submit]'); const submitters = form.querySelectorAll<HTMLButtonElement>('button[type=submit]');
function callback(event: Event): 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;
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) { for (const submitter of submitters) {
// Add the event listener to each submitter. // Add the event listener to each submitter.
submitter.addEventListener('click', callback); submitter.addEventListener('click', event => handleFormSubmit(event, form));
} }
} }
} }

View File

@@ -2,12 +2,12 @@ import SlimSelect from 'slim-select';
import queryString from 'query-string'; import queryString from 'query-string';
import { getApiData, isApiError, getElements, isTruthy, hasError } from '../util'; import { getApiData, isApiError, getElements, isTruthy, hasError } from '../util';
import { createToast } from '../bs'; import { createToast } from '../bs';
import { setOptionStyles, getFilteredBy, toggle } from './util'; import { setOptionStyles, toggle, getDependencyIds } from './util';
import type { Option } from 'slim-select/dist/data'; import type { Option } from 'slim-select/dist/data';
type WithUrl = { type WithUrl = {
url: string; 'data-url': string;
}; };
type WithExclude = { type WithExclude = {
@@ -16,18 +16,16 @@ type WithExclude = {
type ReplaceTuple = [RegExp, string]; type ReplaceTuple = [RegExp, string];
interface CustomSelect<T extends Record<string, string>> extends HTMLSelectElement { type CustomSelect<T extends Record<string, string>> = HTMLSelectElement & T;
dataset: T;
}
function isCustomSelect(el: HTMLSelectElement): el is CustomSelect<WithUrl> { function hasUrl(el: HTMLSelectElement): el is CustomSelect<WithUrl> {
return typeof el?.dataset?.url === 'string'; const value = el.getAttribute('data-url');
return typeof value === 'string' && value !== '';
} }
function hasExclusions(el: HTMLSelectElement): el is CustomSelect<WithExclude> { function hasExclusions(el: HTMLSelectElement): el is CustomSelect<WithExclude> {
return ( const exclude = el.getAttribute('data-query-param-exclude');
typeof el?.dataset?.queryParamExclude === 'string' && el?.dataset?.queryParamExclude !== '' return typeof exclude === 'string' && exclude !== '';
);
} }
const DISABLED_ATTRIBUTES = ['occupied'] as string[]; 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 // existing object. When we fetch options from the API later, we can set any of the options
// contained in this array to `selected`. // contained in this array to `selected`.
const selectOptions = Array.from(select.options) const selectOptions = Array.from(select.options)
.filter(option => option.value !== '') .map(option => option.getAttribute('value'))
.map(option => option.value); .filter(isTruthy);
return getApiData(url).then(data => { const data = await getApiData(url);
if (hasError(data)) { if (hasError(data)) {
if (isApiError(data)) { if (isApiError(data)) {
createToast('danger', data.exception, data.error).show(); createToast('danger', data.exception, data.error).show();
return [PLACEHOLDER];
}
createToast('danger', `Error Fetching Options for field ${select.name}`, data.error).show();
return [PLACEHOLDER]; return [PLACEHOLDER];
} }
createToast('danger', `Error Fetching Options for field ${select.name}`, data.error).show();
return [PLACEHOLDER];
}
const { results } = data; const { results } = data;
const options = [PLACEHOLDER] as Option[]; const options = [PLACEHOLDER] as Option[];
for (const result of results) { for (const result of results) {
const text = getDisplayName(result, select); const text = getDisplayName(result, select);
const data = {} as Record<string, string>; const data = {} as Record<string, string>;
const value = result.id.toString(); const value = result.id.toString();
let style, selected, disabled;
// Set any primitive k/v pairs as data attributes on each option. // Set any primitive k/v pairs as data attributes on each option.
for (const [k, v] of Object.entries(result)) { for (const [k, v] of Object.entries(result)) {
if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) { if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
const key = k.replaceAll('_', '-'); const key = k.replaceAll('_', '-');
data[key] = String(v); 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() { export function initApiSelect() {
for (const select of getElements<HTMLSelectElement>('.netbox-select2-api')) { 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 // Initialize an event, so other elements relying on this element can subscribe to this
// element's value. // element's value.
const event = new Event(`netbox.select.onload.${select.name}`); const event = new Event(`netbox.select.onload.${select.name}`);
// Query Parameters - will have attributes added below. // Query Parameters - will have attributes added below.
const query = {} as Record<string, string>; 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. // 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; // const originalUrl = select.getAttribute('data-url') as string;
// Unpack the original URL with the intent of reassigning it as context updates. // Get the original URL with the intent of reassigning it as context updates.
let { url } = select.dataset; let url = select.getAttribute('data-url') ?? '';
const placeholder = getPlaceholder(select); const placeholder = getPlaceholder(select);
let disabledOptions = [] as string[]; let disabledOptions = [] as string[];
if (hasExclusions(select)) { if (hasExclusions(select)) {
try { 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]; disabledOptions = [...disabledOptions, ...exclusions];
} catch (err) { } catch (err) {
console.warn( console.warn(
@@ -207,7 +211,7 @@ export function initApiSelect() {
const instance = new SlimSelect({ const instance = new SlimSelect({
select, select,
allowDeselect: true, 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, placeholder,
onChange() { onChange() {
const element = instance.slim.container ?? null; const element = instance.slim.container ?? null;
@@ -233,42 +237,52 @@ export function initApiSelect() {
instance.slim.container.classList.remove(className); instance.slim.container.classList.remove(className);
} }
for (let [key, value] of filterMap) { /**
if (value === '') { * Update an element's API URL based on the value of another element upon which this element
// An empty value is set if the key contains a `$`, indicating reliance on another field. * relies.
const elem = document.querySelector(`[name=${key}]`) as HTMLSelectElement; *
if (elem !== null) { * @param id DOM ID of the other element.
groupBy.push(elem); */
if (elem.value !== '') { function updateQuery(id: string) {
// If the element's form value exists, add it to the map. let key = id;
value = elem.value; // Find the element dependency.
filterMap.set(key, elem.value); 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) {
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);
} }
} }
} if (isTruthy(element.value)) {
// Add the dependency's value to the URL query.
// A non-empty value indicates a static query parameter. query[key] = element.value;
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 (url.includes(`{{`) && value !== '') { // Process each of the dependencies, updating this element's URL or other attributes as
// If the URL contains a Django/Jinja template variable, we need to replace the // needed.
// tag with the event's value. for (const dep of dependencies) {
url = url.replaceAll(new RegExp(`{{${key}}}`, 'g'), value); updateQuery(dep);
select.setAttribute('data-url', url);
}
// Add post-replaced key/value pairs to the query object.
if (isTruthy(value)) {
query[key] = value;
}
} }
// Create a valid encoded URL with all query params.
url = queryString.stringifyUrl({ url, query }); url = queryString.stringifyUrl({ url, query });
/** /**
@@ -279,64 +293,35 @@ export function initApiSelect() {
*/ */
function handleEvent(event: Event) { function handleEvent(event: Event) {
const target = event.target as HTMLSelectElement; const target = event.target as HTMLSelectElement;
// Update the element's URL after any changes to a dependency.
if (isTruthy(target.value)) { updateQuery(target.id);
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;
}
// Disable the element while data is loading. // Disable the element while data is loading.
toggle('disable', instance); toggle('disable', instance);
// Load new data. // Load new data.
getOptions(url, select, disabledOptions) getOptions(url, select, disabledOptions)
.then(data => instance.setData(data)) .then(data => instance.setData(data))
.catch(console.error)
.finally(() => { .finally(() => {
// Re-enable the element after data has loaded. // Re-enable the element after data has loaded.
toggle('enable', instance); toggle('enable', instance);
// Inform any event listeners that data has updated. // Inform any event listeners that data has updated.
select.dispatchEvent(event); select.dispatchEvent(event);
}); });
// Stop event bubbling.
event.preventDefault();
} }
for (const group of groupBy) { for (const dep of dependencies) {
// Re-fetch data when the group changes. const element = document.getElementById(`id_${dep}`);
group.addEventListener('change', handleEvent); if (element !== null) {
element.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.${dep}`, handleEvent);
select.addEventListener(`netbox.select.onload.${group.name}`, handleEvent);
} }
// Load data. // Load data.
getOptions(url, select, disabledOptions) getOptions(url, select, disabledOptions)
.then(options => instance.setData(options)) .then(options => instance.setData(options))
.catch(console.error)
.finally(() => { .finally(() => {
// Set option styles, if the field calls for it (color selectors). // Set option styles, if the field calls for it (color selectors).
setOptionStyles(instance); setOptionStyles(instance);

View File

@@ -34,7 +34,7 @@ export function initColorSelect(): void {
select, select,
allowDeselect: true, allowDeselect: true,
// Inherit the calculated color on the deselect icon. // 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. // Style the select container to match any pre-selectd options.

View File

@@ -14,7 +14,7 @@ export function initStaticSelect() {
const instance = new SlimSelect({ const instance = new SlimSelect({
select, select,
allowDeselect: true, allowDeselect: true,
deselectLabel: `<i class="bi bi-x-circle"></i>`, deselectLabel: `<i class="mdi mdi-close-circle"></i>`,
placeholder, placeholder,
}); });

View File

@@ -63,7 +63,7 @@ export function setOptionStyles(instance: SlimSelect): void {
const fg = readableColor(bg); const fg = readableColor(bg);
// Add a unique identifier to the style element. // 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. // Scope the CSS to apply both the list item and the selected item.
style.innerHTML = ` style.innerHTML = `
@@ -155,3 +155,32 @@ export function getFilteredBy<T extends HTMLElement>(element: T): Map<string, st
} }
return filterMap; 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', ''));
}

View File

@@ -3,17 +3,11 @@
{% block title %}{% if obj.pk %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}{% endblock %} {% block title %}{% if obj.pk %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}{% endblock %}
{% block controls %} {% block controls %}
{% if settings.DOCS_ROOT %} {% if settings.DOCS_ROOT %}
<button <button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#docs_modal" title="Help">
type="button" <i class="mdi mdi-help-circle"></i>
class="btn btn-sm btn-outline-secondary" </button>
data-bs-toggle="modal" {% endif %}
data-bs-target="#docs_modal"
title="Help"
>
<i class="mdi mdi-help-circle"></i>
</button>
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -26,7 +20,7 @@
{% block form %} {% block form %}
{% if form.Meta.fieldsets %} {% if form.Meta.fieldsets %}
{# Render grouped fields accoring to Form #} {# Render grouped fields according to Form #}
{% for group, fields in form.Meta.fieldsets %} {% for group, fields in form.Meta.fieldsets %}
<div class="field-group"> <div class="field-group">
<h4 class="mb-3">{{ group }}</h4> <h4 class="mb-3">{{ group }}</h4>