2021-03-13 02:31:57 -07:00
|
|
|
import SlimSelect from 'slim-select';
|
|
|
|
import queryString from 'query-string';
|
2021-04-20 09:34:12 -07:00
|
|
|
import { getApiData, isApiError, getElements, isTruthy, hasError } from '../util';
|
|
|
|
import { createToast } from '../bs';
|
2021-04-22 15:58:46 -07:00
|
|
|
import { setOptionStyles, toggle, getDependencyIds } from './util';
|
2021-03-13 02:31:57 -07:00
|
|
|
|
|
|
|
import type { Option } from 'slim-select/dist/data';
|
|
|
|
|
|
|
|
type WithUrl = {
|
2021-04-22 15:58:46 -07:00
|
|
|
'data-url': string;
|
2021-03-13 02:31:57 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
type WithExclude = {
|
|
|
|
queryParamExclude: string;
|
|
|
|
};
|
|
|
|
|
2021-03-17 12:39:35 -07:00
|
|
|
type ReplaceTuple = [RegExp, string];
|
|
|
|
|
2021-04-22 15:58:46 -07:00
|
|
|
type CustomSelect<T extends Record<string, string>> = HTMLSelectElement & T;
|
2021-03-13 02:31:57 -07:00
|
|
|
|
2021-04-22 15:58:46 -07:00
|
|
|
function hasUrl(el: HTMLSelectElement): el is CustomSelect<WithUrl> {
|
|
|
|
const value = el.getAttribute('data-url');
|
|
|
|
return typeof value === 'string' && value !== '';
|
2021-03-13 02:31:57 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
function hasExclusions(el: HTMLSelectElement): el is CustomSelect<WithExclude> {
|
2021-04-22 15:58:46 -07:00
|
|
|
const exclude = el.getAttribute('data-query-param-exclude');
|
|
|
|
return typeof exclude === 'string' && exclude !== '';
|
2021-03-13 02:31:57 -07:00
|
|
|
}
|
|
|
|
|
2021-03-14 01:06:35 -07:00
|
|
|
const DISABLED_ATTRIBUTES = ['occupied'] as string[];
|
|
|
|
|
2021-03-17 12:39:35 -07:00
|
|
|
// Various one-off patterns to replace in query param keys.
|
|
|
|
const REPLACE_PATTERNS = [
|
|
|
|
// Don't query `termination_a_device=1`, but rather `device=1`.
|
|
|
|
[new RegExp(/termination_(a|b)_(.+)/g), '$2_id'],
|
2021-03-19 09:26:39 -07:00
|
|
|
// A tenant's group relationship field is `group`, but the field name is `tenant_group`.
|
|
|
|
[new RegExp(/tenant_(group)/g), '$1_id'],
|
2021-04-20 09:34:12 -07:00
|
|
|
// Append `_id` to any fields
|
2021-03-19 09:26:39 -07:00
|
|
|
[new RegExp(/^([A-Za-z0-9]+)(_id)?$/g), '$1_id'],
|
2021-03-17 12:39:35 -07:00
|
|
|
] as ReplaceTuple[];
|
|
|
|
|
2021-03-13 02:31:57 -07:00
|
|
|
const PLACEHOLDER = {
|
|
|
|
value: '',
|
|
|
|
text: '',
|
|
|
|
placeholder: true,
|
|
|
|
} as Option;
|
|
|
|
|
2021-03-14 01:06:35 -07:00
|
|
|
/**
|
|
|
|
* Retrieve all objects for this object type.
|
|
|
|
*
|
|
|
|
* @param url API endpoint to query.
|
|
|
|
*
|
|
|
|
* @returns Data parsed into SlimSelect options.
|
|
|
|
*/
|
2021-03-17 12:39:35 -07:00
|
|
|
async function getOptions(
|
2021-03-14 01:06:35 -07:00
|
|
|
url: string,
|
2021-03-17 12:39:35 -07:00
|
|
|
select: HTMLSelectElement,
|
2021-03-14 01:06:35 -07:00
|
|
|
disabledOptions: string[],
|
|
|
|
): Promise<Option[]> {
|
|
|
|
if (url.includes(`{{`)) {
|
|
|
|
return [PLACEHOLDER];
|
|
|
|
}
|
2021-03-17 12:39:35 -07:00
|
|
|
|
|
|
|
// 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
|
|
|
|
// contained in this array to `selected`.
|
|
|
|
const selectOptions = Array.from(select.options)
|
2021-04-22 15:58:46 -07:00
|
|
|
.map(option => option.getAttribute('value'))
|
|
|
|
.filter(isTruthy);
|
|
|
|
|
|
|
|
const data = await getApiData(url);
|
|
|
|
if (hasError(data)) {
|
|
|
|
if (isApiError(data)) {
|
|
|
|
createToast('danger', data.exception, data.error).show();
|
2021-03-14 01:06:35 -07:00
|
|
|
return [PLACEHOLDER];
|
|
|
|
}
|
2021-04-22 15:58:46 -07:00
|
|
|
createToast('danger', `Error Fetching Options for field ${select.name}`, data.error).show();
|
|
|
|
return [PLACEHOLDER];
|
|
|
|
}
|
2021-03-14 01:06:35 -07:00
|
|
|
|
2021-04-22 15:58:46 -07:00
|
|
|
const { results } = data;
|
|
|
|
const options = [PLACEHOLDER] as Option[];
|
2021-03-14 01:06:35 -07:00
|
|
|
|
2021-04-22 15:58:46 -07:00
|
|
|
for (const result of results) {
|
|
|
|
const text = getDisplayName(result, select);
|
|
|
|
const data = {} as Record<string, string>;
|
|
|
|
const value = result.id.toString();
|
|
|
|
let style, selected, disabled;
|
2021-03-14 01:06:35 -07:00
|
|
|
|
2021-04-22 15:58:46 -07:00
|
|
|
// 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);
|
2021-03-17 12:39:35 -07:00
|
|
|
}
|
|
|
|
// Set option to disabled if the result contains a matching key and is truthy.
|
2021-04-22 15:58:46 -07:00
|
|
|
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;
|
|
|
|
}
|
2021-03-17 12:39:35 -07:00
|
|
|
}
|
2021-04-22 15:58:46 -07:00
|
|
|
}
|
2021-03-14 01:06:35 -07:00
|
|
|
|
2021-04-22 15:58:46 -07:00
|
|
|
// Set option to disabled if it is contained within the disabled array.
|
|
|
|
if (selectOptions.some(option => disabledOptions.includes(option))) {
|
|
|
|
disabled = true;
|
|
|
|
}
|
2021-03-14 01:06:35 -07:00
|
|
|
|
2021-04-22 15:58:46 -07:00
|
|
|
// 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;
|
2021-03-14 01:06:35 -07:00
|
|
|
}
|
2021-04-22 15:58:46 -07:00
|
|
|
|
|
|
|
const option = {
|
|
|
|
value,
|
|
|
|
text,
|
|
|
|
data,
|
|
|
|
style,
|
|
|
|
selected,
|
|
|
|
disabled,
|
|
|
|
} as Option;
|
|
|
|
|
|
|
|
options.push(option);
|
|
|
|
}
|
|
|
|
return options;
|
2021-03-14 01:06:35 -07:00
|
|
|
}
|
|
|
|
|
2021-03-17 12:39:35 -07:00
|
|
|
/**
|
|
|
|
* Find the select element's placeholder text/label.
|
|
|
|
*/
|
|
|
|
function getPlaceholder(select: HTMLSelectElement): string {
|
|
|
|
let placeholder = select.name;
|
|
|
|
if (select.id) {
|
|
|
|
const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
|
|
|
|
|
|
|
|
// Set the placeholder text to the label value, if it exists.
|
|
|
|
if (label !== null) {
|
|
|
|
placeholder = `Select ${label.innerText.trim()}`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return placeholder;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Find this field's display name.
|
|
|
|
* @param select
|
|
|
|
* @returns
|
|
|
|
*/
|
|
|
|
function getDisplayName(result: APIObjectBase, select: HTMLSelectElement): string {
|
|
|
|
let displayName = result.display;
|
|
|
|
|
|
|
|
const legacyDisplayProperty = select.getAttribute('display-field');
|
|
|
|
|
|
|
|
if (
|
|
|
|
typeof displayName === 'undefined' &&
|
|
|
|
legacyDisplayProperty !== null &&
|
|
|
|
legacyDisplayProperty in result
|
|
|
|
) {
|
|
|
|
displayName = result[legacyDisplayProperty] as string;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!displayName) {
|
|
|
|
displayName = result.name;
|
|
|
|
}
|
|
|
|
|
|
|
|
return displayName;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initialize select elements that rely on the NetBox API to build their options.
|
|
|
|
*/
|
2021-03-13 02:31:57 -07:00
|
|
|
export function initApiSelect() {
|
2021-03-14 01:06:35 -07:00
|
|
|
for (const select of getElements<HTMLSelectElement>('.netbox-select2-api')) {
|
2021-04-22 15:58:46 -07:00
|
|
|
const dependencies = getDependencyIds(select);
|
2021-03-17 12:39:35 -07:00
|
|
|
// 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.
|
2021-04-30 11:29:45 -07:00
|
|
|
const query = { limit: 0 } as Record<string, string | number>;
|
2021-03-13 02:31:57 -07:00
|
|
|
|
2021-04-22 15:58:46 -07:00
|
|
|
if (hasUrl(select)) {
|
2021-03-17 23:32:01 -07:00
|
|
|
// Store the original URL, so it can be referred back to as filter-by elements change.
|
2021-04-22 15:58:46 -07:00
|
|
|
// 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') ?? '';
|
2021-03-13 02:31:57 -07:00
|
|
|
|
2021-03-17 12:39:35 -07:00
|
|
|
const placeholder = getPlaceholder(select);
|
2021-03-14 01:06:35 -07:00
|
|
|
|
2021-03-13 02:31:57 -07:00
|
|
|
let disabledOptions = [] as string[];
|
|
|
|
if (hasExclusions(select)) {
|
2021-03-14 01:06:35 -07:00
|
|
|
try {
|
2021-04-22 15:58:46 -07:00
|
|
|
const exclusions = JSON.parse(
|
|
|
|
select.getAttribute('data-query-param-exclude') ?? '[]',
|
|
|
|
) as string[];
|
2021-03-14 01:06:35 -07:00
|
|
|
disabledOptions = [...disabledOptions, ...exclusions];
|
|
|
|
} catch (err) {
|
|
|
|
console.warn(
|
|
|
|
`Unable to parse data-query-param-exclude value on select element '${select.name}': ${err}`,
|
|
|
|
);
|
|
|
|
}
|
2021-03-13 02:31:57 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
const instance = new SlimSelect({
|
|
|
|
select,
|
|
|
|
allowDeselect: true,
|
2021-04-22 15:58:46 -07:00
|
|
|
deselectLabel: `<i class="mdi mdi-close-circle" style="color:currentColor;"></i>`,
|
2021-03-13 02:31:57 -07:00
|
|
|
placeholder,
|
2021-03-17 12:39:35 -07:00
|
|
|
onChange() {
|
|
|
|
const element = instance.slim.container ?? null;
|
|
|
|
if (element !== null) {
|
|
|
|
// Reset validity classes if the field was invalid.
|
|
|
|
if (
|
|
|
|
element.classList.contains('is-invalid') ||
|
|
|
|
select.classList.contains('is-invalid')
|
|
|
|
) {
|
|
|
|
select.classList.remove('is-invalid');
|
|
|
|
element.classList.remove('is-invalid');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
select.dispatchEvent(event);
|
|
|
|
},
|
2021-03-13 02:31:57 -07:00
|
|
|
});
|
|
|
|
|
2021-03-14 17:31:06 -07:00
|
|
|
// Disable the element while data has not been loaded.
|
2021-03-15 07:49:32 -07:00
|
|
|
toggle('disable', instance);
|
2021-03-14 17:31:06 -07:00
|
|
|
|
2021-03-14 01:06:35 -07:00
|
|
|
// Don't copy classes from select element to SlimSelect instance.
|
|
|
|
for (const className of select.classList) {
|
|
|
|
instance.slim.container.classList.remove(className);
|
|
|
|
}
|
|
|
|
|
2021-04-22 15:58:46 -07:00
|
|
|
/**
|
|
|
|
* 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) {
|
|
|
|
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);
|
2021-03-17 12:39:35 -07:00
|
|
|
}
|
|
|
|
}
|
2021-04-22 15:58:46 -07:00
|
|
|
if (isTruthy(element.value)) {
|
|
|
|
// Add the dependency's value to the URL query.
|
|
|
|
query[key] = element.value;
|
2021-03-17 12:39:35 -07:00
|
|
|
}
|
|
|
|
}
|
2021-04-22 15:58:46 -07:00
|
|
|
}
|
|
|
|
// Process each of the dependencies, updating this element's URL or other attributes as
|
|
|
|
// needed.
|
|
|
|
for (const dep of dependencies) {
|
|
|
|
updateQuery(dep);
|
2021-03-17 12:39:35 -07:00
|
|
|
}
|
|
|
|
|
2021-04-22 15:58:46 -07:00
|
|
|
// Create a valid encoded URL with all query params.
|
2021-03-17 12:39:35 -07:00
|
|
|
url = queryString.stringifyUrl({ url, query });
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
|
|
|
function handleEvent(event: Event) {
|
|
|
|
const target = event.target as HTMLSelectElement;
|
2021-04-22 15:58:46 -07:00
|
|
|
// Update the element's URL after any changes to a dependency.
|
|
|
|
updateQuery(target.id);
|
2021-03-17 12:39:35 -07:00
|
|
|
|
|
|
|
// Disable the element while data is loading.
|
|
|
|
toggle('disable', instance);
|
|
|
|
// Load new data.
|
|
|
|
getOptions(url, select, disabledOptions)
|
|
|
|
.then(data => instance.setData(data))
|
2021-04-22 15:58:46 -07:00
|
|
|
.catch(console.error)
|
2021-03-17 12:39:35 -07:00
|
|
|
.finally(() => {
|
|
|
|
// Re-enable the element after data has loaded.
|
|
|
|
toggle('enable', instance);
|
|
|
|
// Inform any event listeners that data has updated.
|
|
|
|
select.dispatchEvent(event);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-04-22 15:58:46 -07:00
|
|
|
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);
|
2021-03-17 12:39:35 -07:00
|
|
|
}
|
|
|
|
|
2021-03-14 17:31:06 -07:00
|
|
|
// Load data.
|
2021-03-17 12:39:35 -07:00
|
|
|
getOptions(url, select, disabledOptions)
|
2021-03-14 01:06:35 -07:00
|
|
|
.then(options => instance.setData(options))
|
2021-04-22 15:58:46 -07:00
|
|
|
.catch(console.error)
|
2021-03-14 01:06:35 -07:00
|
|
|
.finally(() => {
|
2021-03-15 07:49:32 -07:00
|
|
|
// Set option styles, if the field calls for it (color selectors).
|
|
|
|
setOptionStyles(instance);
|
2021-03-14 17:31:06 -07:00
|
|
|
// Enable the element after data has loaded.
|
2021-03-15 07:49:32 -07:00
|
|
|
toggle('enable', instance);
|
2021-03-17 12:39:35 -07:00
|
|
|
// Inform any event listeners that data has updated.
|
|
|
|
select.dispatchEvent(event);
|
2021-03-14 01:06:35 -07:00
|
|
|
});
|
|
|
|
|
2021-03-13 02:31:57 -07:00
|
|
|
// 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 seem cleaner in the DOM.
|
|
|
|
const { width, height } = instance.slim.container.getBoundingClientRect();
|
|
|
|
select.style.opacity = '0';
|
|
|
|
select.style.width = `${width}px`;
|
|
|
|
select.style.height = `${height}px`;
|
|
|
|
select.style.display = 'block';
|
|
|
|
select.style.position = 'absolute';
|
|
|
|
select.style.pointerEvents = 'none';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|