mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
fix select data handling
This commit is contained in:
@ -1,6 +1,6 @@
|
|||||||
import SlimSelect from 'slim-select';
|
import SlimSelect from 'slim-select';
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
import { getApiData, isApiError } from '../util';
|
import { getApiData, isApiError, getElements } from '../util';
|
||||||
import { createToast } from '../toast';
|
import { createToast } from '../toast';
|
||||||
import { setOptionStyles, getFilteredBy } from './util';
|
import { setOptionStyles, getFilteredBy } from './util';
|
||||||
|
|
||||||
@ -28,15 +28,88 @@ function hasExclusions(el: HTMLSelectElement): el is CustomSelect<WithExclude> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DISABLED_ATTRIBUTES = ['occupied'] as string[];
|
||||||
|
|
||||||
const PLACEHOLDER = {
|
const PLACEHOLDER = {
|
||||||
value: '',
|
value: '',
|
||||||
text: '',
|
text: '',
|
||||||
placeholder: true,
|
placeholder: true,
|
||||||
} as Option;
|
} as Option;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve all objects for this object type.
|
||||||
|
*
|
||||||
|
* @param url API endpoint to query.
|
||||||
|
*
|
||||||
|
* @returns Data parsed into SlimSelect options.
|
||||||
|
*/
|
||||||
|
async function getChoices(
|
||||||
|
url: string,
|
||||||
|
displayField: string,
|
||||||
|
selectOptions: string[],
|
||||||
|
disabledOptions: string[],
|
||||||
|
): Promise<Option[]> {
|
||||||
|
if (url.includes(`{{`)) {
|
||||||
|
return [PLACEHOLDER];
|
||||||
|
}
|
||||||
|
return getApiData(url).then(data => {
|
||||||
|
if (isApiError(data)) {
|
||||||
|
const toast = createToast('danger', data.exception, data.error);
|
||||||
|
toast.show();
|
||||||
|
return [PLACEHOLDER];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = data;
|
||||||
|
const options = [PLACEHOLDER] as Option[];
|
||||||
|
|
||||||
|
if (results.length !== 0) {
|
||||||
|
for (const result of results) {
|
||||||
|
const data = {} as Record<string, string>;
|
||||||
|
const value = result.id.toString();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 choice = {
|
||||||
|
value,
|
||||||
|
text: result[displayField],
|
||||||
|
data,
|
||||||
|
style,
|
||||||
|
selected,
|
||||||
|
disabled,
|
||||||
|
} as Option;
|
||||||
|
|
||||||
|
options.push(choice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function initApiSelect() {
|
export function initApiSelect() {
|
||||||
const elements = document.querySelectorAll<HTMLSelectElement>('.netbox-select2-api');
|
for (const select of getElements<HTMLSelectElement>('.netbox-select2-api')) {
|
||||||
for (const select of elements) {
|
|
||||||
// Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
|
// Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
|
||||||
// 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`.
|
||||||
@ -46,6 +119,7 @@ export function initApiSelect() {
|
|||||||
|
|
||||||
const filteredBy = getFilteredBy(select);
|
const filteredBy = getFilteredBy(select);
|
||||||
const filterMap = new Map<string, string>();
|
const filterMap = new Map<string, string>();
|
||||||
|
const event = new Event(`netbox.select.load.${select.name}`);
|
||||||
|
|
||||||
if (isCustomSelect(select)) {
|
if (isCustomSelect(select)) {
|
||||||
let { url } = select.dataset;
|
let { url } = select.dataset;
|
||||||
@ -60,9 +134,17 @@ export function initApiSelect() {
|
|||||||
placeholder = `Select ${label.innerText.trim()}`;
|
placeholder = `Select ${label.innerText.trim()}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let disabledOptions = [] as string[];
|
let disabledOptions = [] as string[];
|
||||||
if (hasExclusions(select)) {
|
if (hasExclusions(select)) {
|
||||||
disabledOptions = JSON.parse(select.dataset.queryParamExclude) as string[];
|
try {
|
||||||
|
const exclusions = JSON.parse(select.dataset.queryParamExclude) as string[];
|
||||||
|
disabledOptions = [...disabledOptions, ...exclusions];
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`Unable to parse data-query-param-exclude value on select element '${select.name}': ${err}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const instance = new SlimSelect({
|
const instance = new SlimSelect({
|
||||||
@ -72,6 +154,20 @@ export function initApiSelect() {
|
|||||||
placeholder,
|
placeholder,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Don't copy classes from select element to SlimSelect instance.
|
||||||
|
for (const className of select.classList) {
|
||||||
|
instance.slim.container.classList.remove(className);
|
||||||
|
}
|
||||||
|
|
||||||
|
getChoices(url, displayField, selectOptions, disabledOptions)
|
||||||
|
.then(options => instance.setData(options))
|
||||||
|
.finally(() => {
|
||||||
|
// Set option styles, if the field calls for it (color selectors).
|
||||||
|
setOptionStyles(instance);
|
||||||
|
// Inform any event listeners that data has updated.
|
||||||
|
select.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
// Reset validity classes if the field was invalid.
|
// Reset validity classes if the field was invalid.
|
||||||
instance.onChange = () => {
|
instance.onChange = () => {
|
||||||
const element = instance.slim.container ?? null;
|
const element = instance.slim.container ?? null;
|
||||||
@ -83,11 +179,6 @@ export function initApiSelect() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't copy classes from select element to SlimSelect instance.
|
|
||||||
for (const className of select.classList) {
|
|
||||||
instance.slim.container.classList.remove(className);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the underlying select element to the same size as the SlimSelect instance.
|
// 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,
|
// This is primarily for built-in HTML form validation, which doesn't really work,
|
||||||
// but it also makes things seem cleaner in the DOM.
|
// but it also makes things seem cleaner in the DOM.
|
||||||
@ -99,66 +190,12 @@ export function initApiSelect() {
|
|||||||
select.style.position = 'absolute';
|
select.style.position = 'absolute';
|
||||||
select.style.pointerEvents = 'none';
|
select.style.pointerEvents = 'none';
|
||||||
|
|
||||||
/**
|
for (const filter of filteredBy) {
|
||||||
* Retrieve all objects for this object type.
|
// Find element with the `name` attribute matching this element's filtered-by attribute.
|
||||||
*
|
const groupElem = document.querySelector(`[name=${filter}]`) as HTMLSelectElement;
|
||||||
* @param choiceUrl Optionally override the URL for filtering. If not set, the URL
|
|
||||||
* from the DOM attributes is used.
|
|
||||||
* @returns Data parsed into Choices.JS Choices.
|
|
||||||
*/
|
|
||||||
async function getChoices(choiceUrl: string = url): Promise<Option[]> {
|
|
||||||
if (choiceUrl.includes(`{{`)) {
|
|
||||||
return [PLACEHOLDER];
|
|
||||||
}
|
|
||||||
return getApiData(choiceUrl).then(data => {
|
|
||||||
if (isApiError(data)) {
|
|
||||||
const toast = createToast('danger', data.exception, data.error);
|
|
||||||
toast.show();
|
|
||||||
return [PLACEHOLDER];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { results } = data;
|
if (groupElem !== null) {
|
||||||
const options = [PLACEHOLDER] as Option[];
|
if (groupElem.value) {
|
||||||
|
|
||||||
if (results.length !== 0) {
|
|
||||||
for (const result of results) {
|
|
||||||
const data = {} as Record<string, string>;
|
|
||||||
const value = result.id.toString();
|
|
||||||
let style, selected;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (selectOptions.includes(value)) {
|
|
||||||
selected = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const choice = {
|
|
||||||
value,
|
|
||||||
text: result[displayField],
|
|
||||||
data,
|
|
||||||
style,
|
|
||||||
selected,
|
|
||||||
} as Option;
|
|
||||||
|
|
||||||
options.push(choice);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filteredBy.length !== 0) {
|
|
||||||
for (const filter of filteredBy) {
|
|
||||||
// Find element with the `name` attribute matching this element's filtered-by attribute.
|
|
||||||
const groupElem = document.querySelector(`[name=${filter}]`) as HTMLSelectElement;
|
|
||||||
|
|
||||||
if (groupElem !== null) {
|
|
||||||
// Add the group's value to the filtered-by map.
|
// Add the group's value to the filtered-by map.
|
||||||
filterMap.set(filter, groupElem.value);
|
filterMap.set(filter, groupElem.value);
|
||||||
// If the URL contains a Django/Jinja template variable tag, we need to replace the tag
|
// If the URL contains a Django/Jinja template variable tag, we need to replace the tag
|
||||||
@ -167,50 +204,59 @@ export function initApiSelect() {
|
|||||||
url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), groupElem.value);
|
url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), groupElem.value);
|
||||||
select.setAttribute('data-url', url);
|
select.setAttribute('data-url', url);
|
||||||
}
|
}
|
||||||
/**
|
}
|
||||||
* When the group's selection changes, re-query the dependant element's options, but
|
/**
|
||||||
* filtered to results matching the group's ID.
|
* When the group's selection changes, re-query the dependant element's options, but
|
||||||
*
|
* filtered to results matching the group's ID.
|
||||||
* @param event Group's DOM event.
|
*
|
||||||
*/
|
* @param event Group's DOM event.
|
||||||
function handleEvent(event: Event) {
|
*/
|
||||||
let filterUrl: string | undefined;
|
function handleEvent(event: Event) {
|
||||||
|
let filterUrl = url;
|
||||||
|
|
||||||
const target = event.target as HTMLSelectElement;
|
const target = event.target as HTMLSelectElement;
|
||||||
|
|
||||||
if (target.value) {
|
if (target.value) {
|
||||||
let filterValue = filterMap.get(target.value);
|
let filterValue = target.name;
|
||||||
if (url.includes(`{{`) && typeof filterValue !== 'undefined') {
|
|
||||||
// If the URL contains a Django/Jinja template variable tag, we need to replace
|
|
||||||
// the tag with the event's value.
|
|
||||||
url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), filterValue);
|
|
||||||
select.setAttribute('data-url', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
let queryKey = filterValue;
|
if (typeof filterValue === 'undefined') {
|
||||||
if (filter?.includes('_group')) {
|
filterMap.set(target.name, target.value);
|
||||||
// For example, a tenant's group relationship field is `group`, but the field
|
}
|
||||||
// name is `tenant_group`.
|
|
||||||
queryKey = 'group';
|
if (url.includes(`{{`) && typeof filterValue !== 'undefined') {
|
||||||
}
|
// If the URL contains a Django/Jinja template variable tag, we need to replace
|
||||||
|
// the tag with the event's value.
|
||||||
|
url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), filterValue);
|
||||||
|
select.setAttribute('data-url', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let queryKey = filterValue;
|
||||||
|
if (filter?.includes('_group')) {
|
||||||
|
// For example, a tenant's group relationship field is `group`, but the field
|
||||||
|
// name is `tenant_group`.
|
||||||
|
queryKey = 'group';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof queryKey !== 'undefined') {
|
||||||
filterUrl = queryString.stringifyUrl({
|
filterUrl = queryString.stringifyUrl({
|
||||||
url,
|
url,
|
||||||
query: { [`${queryKey}_id`]: groupElem.value },
|
query: { [`${queryKey}_id`]: groupElem.value },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getChoices(filterUrl).then(data => instance.setData(data));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
groupElem.addEventListener('change', handleEvent);
|
getChoices(filterUrl, displayField, selectOptions, disabledOptions).then(data =>
|
||||||
groupElem.addEventListener('change', handleEvent);
|
instance.setData(data),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
// Re-fetch data when the group changes.
|
||||||
|
groupElem.addEventListener('change', handleEvent);
|
||||||
|
|
||||||
|
// Subscribe this instance (the child that relies on groupElem) to any changes of the
|
||||||
|
// group's value, so we can re-render options.
|
||||||
|
select.addEventListener(`netbox.select.onload.${groupElem.name}`, handleEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getChoices()
|
|
||||||
.then(data => instance.setData(data))
|
|
||||||
.finally(() => setOptionStyles(instance));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,27 @@
|
|||||||
import SlimSelect from 'slim-select';
|
import SlimSelect from 'slim-select';
|
||||||
|
import { getElements } from '../util';
|
||||||
|
|
||||||
export function initStaticSelect() {
|
export function initStaticSelect() {
|
||||||
const elements = document.querySelectorAll(
|
for (const select of getElements<HTMLSelectElement>('.netbox-select2-static')) {
|
||||||
'.netbox-select2-static',
|
|
||||||
) as NodeListOf<HTMLSelectElement>;
|
|
||||||
|
|
||||||
for (const select of elements) {
|
|
||||||
if (select !== null) {
|
if (select !== null) {
|
||||||
const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
|
const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
|
||||||
|
|
||||||
let placeholder;
|
let placeholder;
|
||||||
if (label !== null) {
|
if (label !== null) {
|
||||||
placeholder = `Select ${label.innerText.trim()}`;
|
placeholder = `Select ${label.innerText.trim()}`;
|
||||||
}
|
}
|
||||||
new SlimSelect({
|
|
||||||
|
const instance = new SlimSelect({
|
||||||
select,
|
select,
|
||||||
allowDeselect: true,
|
allowDeselect: true,
|
||||||
deselectLabel: `<i class="bi bi-x-circle"></i>`,
|
deselectLabel: `<i class="bi bi-x-circle"></i>`,
|
||||||
placeholder,
|
placeholder,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Don't copy classes from select element to SlimSelect instance.
|
||||||
|
for (const className of select.classList) {
|
||||||
|
instance.slim.container.classList.remove(className);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user