1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00
Files
netbox-community-netbox/netbox/project-static/src/select/api.ts

362 lines
12 KiB
TypeScript
Raw Normal View History

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-03-15 07:49:32 -07:00
import { setOptionStyles, getFilteredBy, toggle } from './util';
2021-03-13 02:31:57 -07:00
import type { Option } from 'slim-select/dist/data';
type WithUrl = {
url: string;
};
type WithExclude = {
queryParamExclude: string;
};
type ReplaceTuple = [RegExp, string];
2021-03-13 02:31:57 -07:00
interface CustomSelect<T extends Record<string, string>> extends HTMLSelectElement {
dataset: T;
}
function isCustomSelect(el: HTMLSelectElement): el is CustomSelect<WithUrl> {
return typeof el?.dataset?.url === 'string';
}
function hasExclusions(el: HTMLSelectElement): el is CustomSelect<WithExclude> {
return (
typeof el?.dataset?.queryParamExclude === 'string' && el?.dataset?.queryParamExclude !== ''
);
}
2021-03-14 01:06:35 -07:00
const DISABLED_ATTRIBUTES = ['occupied'] as string[];
// 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'],
] 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.
*/
async function getOptions(
2021-03-14 01:06:35 -07:00
url: string,
select: HTMLSelectElement,
2021-03-14 01:06:35 -07:00
disabledOptions: string[],
): Promise<Option[]> {
if (url.includes(`{{`)) {
return [PLACEHOLDER];
}
// 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)
.filter(option => option.value !== '')
.map(option => option.value);
2021-03-14 01:06:35 -07:00
return getApiData(url).then(data => {
2021-04-20 09:34:12 -07:00
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();
2021-03-14 01:06:35 -07:00
return [PLACEHOLDER];
}
const { results } = data;
const options = [PLACEHOLDER] as Option[];
for (const result of results) {
const text = getDisplayName(result, select);
const data = {} as Record<string, string>;
const value = result.id.toString();
2021-03-14 01:06:35 -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-14 01:06:35 -07:00
}
}
2021-03-14 01:06:35 -07:00
let style, selected, disabled;
2021-03-14 01:06:35 -07:00
// Set pre-selected options.
if (selectOptions.includes(value)) {
selected = true;
}
2021-03-14 01:06:35 -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
// 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;
}
2021-03-14 01:06:35 -07:00
const option = {
value,
text,
data,
style,
selected,
disabled,
} as Option;
2021-03-14 01:06:35 -07:00
options.push(option);
2021-03-14 01:06:35 -07:00
}
return options;
});
}
/**
* 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')) {
const filterMap = getFilteredBy(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[];
2021-03-13 02:31:57 -07:00
if (isCustomSelect(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.
const originalUrl = JSON.parse(JSON.stringify(select.dataset.url)) as string;
// Unpack the original URL with the intent of reassigning it as context updates.
2021-03-13 02:31:57 -07:00
let { url } = select.dataset;
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 {
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}`,
);
}
2021-03-13 02:31:57 -07:00
}
const instance = new SlimSelect({
select,
allowDeselect: true,
deselectLabel: `<i class="bi bi-x-circle" style="color:currentColor;"></i>`,
placeholder,
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);
}
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.
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);
2021-03-19 09:26:39 -07:00
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);
select.setAttribute('data-url', url);
}
// Add post-replaced key/value pairs to the query object.
if (isTruthy(value)) {
query[key] = value;
}
}
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;
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);
2021-03-19 09:26:39 -07:00
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 });
2021-03-17 23:32:01 -07:00
} else {
url = originalUrl;
}
// Disable the element while data is loading.
toggle('disable', instance);
// Load new data.
getOptions(url, select, disabledOptions)
.then(data => instance.setData(data))
.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);
}
2021-03-14 17:31:06 -07:00
// Load data.
getOptions(url, select, disabledOptions)
2021-03-14 01:06:35 -07:00
.then(options => instance.setData(options))
.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);
// 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';
}
}
}