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

725 lines
24 KiB
TypeScript
Raw Normal View History

2021-03-13 02:31:57 -07:00
import queryString from 'query-string';
import { readableColor } from 'color2k';
import SlimSelect from 'slim-select';
import { createToast } from '../bs';
import { hasUrl, hasExclusions } from './util';
import {
isTruthy,
hasError,
getElement,
getApiData,
isApiError,
getElements,
findFirstAdjacent,
} from '../util';
2021-03-13 02:31:57 -07:00
import type { Option } from 'slim-select/dist/data';
type QueryFilter = Map<string, string | number>;
2021-03-14 01:06: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'],
] as [RegExp, string][];
// Empty placeholder option.
2021-03-13 02:31:57 -07:00
const PLACEHOLDER = {
value: '',
text: '',
placeholder: true,
} as Option;
// Attributes which if truthy should render the option disabled.
const DISABLED_ATTRIBUTES = ['occupied'] as string[];
2021-03-14 01:06:35 -07:00
/**
* Manage a single API-backed select element's state. Each API select element is likely controlled
* or dynamically updated by one or more other API select (or static select) elements' values.
2021-03-14 01:06:35 -07:00
*/
class APISelect {
/**
* Base `<select/>` DOM element.
*/
private readonly base: HTMLSelectElement;
/**
* Form field name.
*/
public readonly name: string;
/**
* Form field placeholder.
*/
public readonly placeholder: string;
/**
* This element's options come from the server pre-sorted and should not be sorted client-side.
* Determined by the existence of the `pre-sorted` attribute on the base `<select/>` element.
*/
private readonly preSorted: boolean = false;
/**
* Event to be dispatched when dependent fields' values change.
*/
private readonly loadEvent: InstanceType<typeof Event>;
/**
* SlimSelect instance for this element.
*/
private readonly slim: InstanceType<typeof SlimSelect>;
/**
* API query parameters that should be applied to API queries for this field. This will be
* updated as other dependent fields' values change. This is a mapping of:
*
* Form Field Names Form Field Values
*
* This is/might be different than the query parameters themselves, as the form field names may
* be different than the object model key names. For example, `tenant_group` would be the field
* name, but `group` would be the query parameter. Query parameters themselves are tracked in
* `queryParams`.
*/
private readonly filterParams: QueryFilter = new Map();
/**
* Post-parsed URL query parameters for API queries.
*/
private readonly queryParams: QueryFilter = new Map();
/**
* Mapping of URL template key/value pairs. If this element's URL contains Django template tags
* (e.g., `{{key}}`), `key` will be added to `pathValue` and the `id_key` form element will be
* tracked for changes. When the `id_key` element's value changes, the new value will be added
* to this map. For example, if the template key is `rack`, and the `id_rack` field's value is
* `1`, `pathValues` would be updated to reflect a `"rack" => 1` mapping. When the query URL is
* updated, the URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
*/
private readonly pathValues: QueryFilter = new Map();
/**
* Original API query URL passed via the `data-href` attribute from the server. This is kept so
* that the URL can be reconstructed as form values change.
*/
private readonly url: string = '';
/**
* API query URL. This will be updated dynamically to include any query parameters in `queryParameters`.
*/
private queryUrl: string = '';
/**
* This instance's available options.
*/
private _options: Option[] = [PLACEHOLDER];
/**
* Array of options values which should be considered disabled or static.
*/
private disabledOptions: Array<string> = [];
constructor(base: HTMLSelectElement) {
// Initialize readonly properties.
this.base = base;
this.name = base.name;
if (base.getAttribute('pre-sorted') !== null) {
this.preSorted = true;
}
if (hasUrl(base)) {
const url = base.getAttribute('data-url') as string;
this.url = url;
this.queryUrl = url;
}
this.loadEvent = new Event(`netbox.select.onload.${base.name}`);
this.placeholder = this.getPlaceholder();
this.disabledOptions = this.getDisabledOptions();
this.slim = new SlimSelect({
select: this.base,
allowDeselect: true,
deselectLabel: `<i class="mdi mdi-close-circle" style="color:currentColor;"></i>`,
placeholder: this.placeholder,
onChange: () => this.handleSlimChange(),
});
// Initialize API query properties.
this.getFilteredBy();
this.getPathKeys();
for (const filter of this.filterParams.keys()) {
this.updateQueryParams(filter);
}
for (const filter of this.pathValues.keys()) {
this.updatePathValues(filter);
}
this.queryParams.set('limit', 0);
this.updateQueryUrl();
// Initialize element styling.
this.resetClasses();
this.setSlimStyles();
// Initialize controlling elements.
this.initResetButton();
// Add dependency event listeners.
this.addEventListeners();
// Determine if this element is part of collapsible element.
const collapse = findFirstAdjacent(this.base, '.collapse', '.content-container');
if (collapse !== null) {
// If this element is part of a collapsible element, only load the data when the
// collapsible element is shown.
// See: https://getbootstrap.com/docs/5.0/components/collapse/#events
collapse.addEventListener('show.bs.collapse', () => this.loadData());
collapse.addEventListener('hide.bs.collapse', () => this.resetOptions());
} else {
// Otherwise, load the data on render.
Promise.all([this.loadData()]);
}
2021-03-14 01:06:35 -07:00
}
/**
* This instance's available options.
*/
public get options(): Option[] {
return this._options;
}
/**
* Sort incoming options by label and apply the new options to both the SlimSelect instance and
* this manager's state. If the `preSorted` attribute exists on the base `<select/>` element,
* the options will *not* be sorted.
*/
private set options(optionsIn: Option[]) {
let newOptions = optionsIn;
if (!this.preSorted) {
newOptions = optionsIn.sort((a, b) => (a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1));
2021-03-14 01:06:35 -07:00
}
this._options = newOptions;
this.slim.setData(newOptions);
}
2021-03-14 01:06:35 -07:00
/**
* Remove all options and reset back to the generic placeholder.
*/
private resetOptions(): void {
this.options = [PLACEHOLDER];
}
2021-03-14 01:06:35 -07:00
/**
* Add or remove a class to the SlimSelect element to match Bootstrap .form-select:disabled styles.
*/
public disable(): void {
if (this.slim.slim.singleSelected !== null) {
if (!this.slim.slim.singleSelected.container.hasAttribute('disabled')) {
this.slim.slim.singleSelected.container.setAttribute('disabled', '');
}
} else if (this.slim.slim.multiSelected !== null) {
if (!this.slim.slim.multiSelected.container.hasAttribute('disabled')) {
this.slim.slim.multiSelected.container.setAttribute('disabled', '');
}
}
}
2021-03-14 01:06:35 -07:00
/**
* Add or remove a class to the SlimSelect element to match Bootstrap .form-select:disabled styles.
*/
public enable(): void {
if (this.slim.slim.singleSelected !== null) {
if (this.slim.slim.singleSelected.container.hasAttribute('disabled')) {
this.slim.slim.singleSelected.container.removeAttribute('disabled');
}
} else if (this.slim.slim.multiSelected !== null) {
if (this.slim.slim.multiSelected.container.hasAttribute('disabled')) {
this.slim.slim.multiSelected.container.removeAttribute('disabled');
}
}
}
2021-03-14 01:06:35 -07:00
/**
* Add event listeners to this element and its dependencies so that when dependencies change
* this element's options are updated.
*/
private addEventListeners(): void {
// Create a unique iterator of all possible form fields which, when changed, should cause this
// element to update its API query.
const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
for (const dep of dependencies) {
const filterElement = document.querySelector(`[name="${dep}"]`);
if (filterElement !== null) {
// Subscribe to dependency changes.
filterElement.addEventListener('change', event => this.handleEvent(event));
}
// Subscribe to changes dispatched by this state manager.
this.base.addEventListener(`netbox.select.onload.${dep}`, event => this.handleEvent(event));
}
}
2021-03-14 01:06:35 -07:00
/**
* Load this element's options from the NetBox API.
*/
private async loadData(): Promise<void> {
try {
this.disable();
await this.getOptions();
} catch (err) {
console.error(err);
} finally {
this.setOptionStyles();
this.enable();
this.base.dispatchEvent(this.loadEvent);
2021-03-14 01:06:35 -07:00
}
}
/**
* Query the NetBox API for this element's options.
*/
private async getOptions(): Promise<void> {
if (this.queryUrl.includes(`{{`)) {
this.options = [PLACEHOLDER];
return;
}
// 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(this.base.options)
.filter(option => option.selected)
.map(option => option.getAttribute('value'))
.filter(isTruthy);
2021-03-14 01:06:35 -07:00
const data = await getApiData(this.queryUrl);
if (hasError(data)) {
if (isApiError(data)) {
return this.handleError(data.exception, data.error);
}
return this.handleError(`Error Fetching Options for field '${this.name}'`, data.error);
}
const { results } = data;
const options = [PLACEHOLDER] as Option[];
for (const result of results) {
const text = this.getDisplayName(result);
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)) {
if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
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;
}
}
}
// Set option to disabled if it is contained within the disabled array.
if (selectOptions.some(option => this.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);
}
this.options = options;
}
/**
* Event handler to be dispatched any time a dependency's value changes. For example, when the
* value of `tenant_group` changes, `handleEvent` is called to get the current value of
* `tenant_group` and update the query parameters and API query URL for the `tenant` field.
*/
private handleEvent(event: Event): void {
const target = event.target as HTMLSelectElement;
// Update the element's URL after any changes to a dependency.
this.updateQueryParams(target.name);
this.updatePathValues(target.name);
this.updateQueryUrl();
// Load new data.
Promise.all([this.loadData()]);
}
/**
* When the API returns an error, show it to the user and reset this element's available options.
*
* @param title Error title
* @param message Error message
*/
private handleError(title: string, message: string): void {
createToast('danger', title, message).show();
this.resetOptions();
}
/**
* `change` event callback to be called any time the value of a SlimSelect instance is changed.
*/
private handleSlimChange(): void {
const element = this.slim.slim;
if (element) {
// Toggle form validation classes when form values change. For example, if the field was
// invalid and the value has now changed, remove the `.is-invalid` class.
if (
element.container.classList.contains('is-invalid') ||
this.base.classList.contains('is-invalid')
) {
element.container.classList.remove('is-invalid');
this.base.classList.remove('is-invalid');
2021-03-13 02:31:57 -07:00
}
}
this.base.dispatchEvent(this.loadEvent);
}
2021-03-13 02:31:57 -07:00
/**
* Update the API query URL and underlying DOM element's `data-url` attribute.
*/
private updateQueryUrl(): void {
// Create new URL query parameters based on the current state of `queryParams` and create an
// updated API query URL.
const query = {} as Record<string, string | number>;
for (const [key, value] of this.queryParams.entries()) {
query[key] = value;
}
2021-03-13 02:31:57 -07:00
let url = this.url;
2021-03-14 17:31:06 -07:00
// Replace any Django template variables in the URL with values from `pathValues` if set.
for (const [key, value] of this.pathValues.entries()) {
for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
if (isTruthy(value)) {
url = url.replaceAll(result[1], value.toString());
}
2021-03-14 01:06:35 -07:00
}
}
const newUrl = queryString.stringifyUrl({ url, query });
if (this.queryUrl !== newUrl) {
// Only update the URL if it has changed.
this.queryUrl = newUrl;
this.base.setAttribute('data-url', newUrl);
}
}
2021-03-14 01:06:35 -07:00
/**
* Update an element's API URL based on the value of another element on which this element
* relies.
*
* @param id DOM ID of the other element.
*/
private updateQueryParams(id: string): void {
let key = id.replaceAll(/^id_/gi, '');
// Find the element dependency.
const element = getElement<HTMLSelectElement>(`id_${key}`);
if (element !== null) {
// 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 (key.match(pattern)) {
key = key.replaceAll(pattern, replacement);
break;
}
}
if (isTruthy(element.value)) {
// If the field has a value, add it to the map.
if (this.filterParams.has(id)) {
// If this element is tracking the neighbor element, add its value to the map.
this.queryParams.set(key, element.value);
}
} else {
// Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
this.queryParams.delete(key);
}
}
}
/**
* Update `pathValues` based on the form value of another element.
*
* @param id DOM ID of the other element.
*/
private updatePathValues(id: string): void {
let key = id.replaceAll(/^id_/gi, '');
const element = getElement<HTMLSelectElement>(`id_${key}`);
if (element !== null) {
// 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/`.
const hasReplacement =
this.url.includes(`{{`) && Boolean(this.url.match(new RegExp(`({{(${id})}})`, 'g')));
if (hasReplacement) {
if (isTruthy(element.value)) {
// If the field has a value, add it to the map.
this.pathValues.set(id, element.value);
} else {
// Otherwise, reset the value.
this.pathValues.set(id, '');
}
}
}
}
/**
* Find the select element's placeholder text/label.
*/
private getPlaceholder(): string {
let placeholder = this.name;
if (this.base.id) {
const label = document.querySelector(`label[for=${this.base.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 element's display name.
*/
private getDisplayName(result: APIObjectBase): string {
let displayName = result.display;
const legacyDisplayProperty = this.base.getAttribute('display-field');
if (
typeof displayName === 'undefined' &&
legacyDisplayProperty !== null &&
legacyDisplayProperty in result
) {
displayName = result[legacyDisplayProperty] as string;
}
if (!displayName) {
displayName = result.name;
}
return displayName;
}
/**
* Get this element's disabled options by value. The `data-query-param-exclude` attribute will
* contain a stringified JSON array of option values.
*/
private getDisabledOptions(): string[] {
let disabledOptions = [] as string[];
if (hasExclusions(this.base)) {
try {
const exclusions = JSON.parse(
this.base.getAttribute('data-query-param-exclude') ?? '[]',
) as string[];
disabledOptions = [...disabledOptions, ...exclusions];
} catch (err) {
console.group(
`Unable to parse data-query-param-exclude value on select element '${this.name}'`,
);
console.warn(err);
console.groupEnd();
}
}
return disabledOptions;
}
/**
* Parse the `data-url` attribute to add any Django template variables to `pathValues` as keys
* with empty values. As those keys' corresponding form fields' values change, `pathValues` will
* be updated to reflect the new value.
*/
private getPathKeys() {
for (const result of this.url.matchAll(new RegExp(`{{(.+)}}`, 'g'))) {
this.pathValues.set(result[1], '');
}
}
/**
* Determine if a select element should be filtered by the value of another select element.
*
* Looks for the DOM attribute `data-query-param-<name of other field>`, which would look like:
* `["$<name>"]`
*
* If the attribute exists, parse out the raw value. In the above example, this would be `name`.
*/
private getFilteredBy(): void {
const pattern = new RegExp(/\[|\]|"|\$/g);
const keyPattern = new RegExp(/data-query-param-/g);
// Extract data attributes.
const keys = Object.values(this.base.attributes)
.map(v => v.name)
.filter(v => v.includes('data'));
for (const key of keys) {
if (key.match(keyPattern) && key !== 'data-query-param-exclude') {
const value = this.base.getAttribute(key);
if (value !== null) {
try {
const parsed = JSON.parse(value) as string | string[];
if (Array.isArray(parsed)) {
for (const item of parsed) {
if (item.match(/^\$.+$/g)) {
const replaced = item.replaceAll(pattern, '');
this.filterParams.set(replaced, '');
} else {
this.filterParams.set(key.replaceAll(keyPattern, ''), item);
}
}
} else {
if (parsed.match(/^\$.+$/g)) {
const replaced = parsed.replaceAll(pattern, '');
this.filterParams.set(replaced, '');
} else {
this.filterParams.set(key.replaceAll(keyPattern, ''), parsed);
}
}
} catch (err) {
console.warn(err);
if (value.match(/^\$.+$/g)) {
const replaced = value.replaceAll(pattern, '');
this.filterParams.set(replaced, '');
} else {
this.filterParams.set(key.replaceAll(keyPattern, ''), value);
}
}
}
}
}
}
/**
* 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 feel cleaner in the DOM.
*/
private setSlimStyles(): void {
const { width, height } = this.slim.slim.container.getBoundingClientRect();
this.base.style.opacity = '0';
this.base.style.width = `${width}px`;
this.base.style.height = `${height}px`;
this.base.style.display = 'block';
this.base.style.position = 'absolute';
this.base.style.pointerEvents = 'none';
}
/**
* Add scoped style elements specific to each SlimSelect option, if the color property exists.
* As of this writing, this attribute only exist on Tags. The color property is used as the
* background color, and a foreground color is detected based on the luminosity of the background
* color.
*/
private setOptionStyles(): void {
for (const option of this.options) {
// Only create style elements for options that contain a color attribute.
if (
'data' in option &&
'id' in option &&
typeof option.data !== 'undefined' &&
typeof option.id !== 'undefined' &&
'color' in option.data
) {
const id = option.id as string;
const data = option.data as { color: string };
// Create the style element.
const style = document.createElement('style');
// Append hash to color to make it a valid hex color.
const bg = `#${data.color}`;
// Detect the foreground color.
const fg = readableColor(bg);
// Add a unique identifier to the style element.
style.setAttribute('data-netbox', id);
// Scope the CSS to apply both the list item and the selected item.
style.innerHTML = `
div.ss-values div.ss-value[data-id="${id}"],
div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
{
background-color: ${bg} !important;
color: ${fg} !important;
}
`
.replaceAll('\n', '')
.trim();
// Add the style element to the DOM.
document.head.appendChild(style);
}
}
}
/**
* Remove base element classes from SlimSelect instance.
*/
private resetClasses(): void {
const element = this.slim.slim;
if (element) {
for (const className of this.base.classList) {
element.container.classList.remove(className);
}
}
}
/**
* Initialize any adjacent reset buttons so that when clicked, the instance's selected value is cleared.
*/
private initResetButton(): void {
const resetButton = findFirstAdjacent<HTMLButtonElement>(this.base, 'button[data-reset-select');
if (resetButton !== null) {
resetButton.addEventListener('click', () => {
this.base.value = '';
if (this.base.multiple) {
this.slim.setSelected([]);
} else {
this.slim.setSelected('');
}
});
2021-03-13 02:31:57 -07:00
}
}
}
export function initApiSelect() {
for (const select of getElements<HTMLSelectElement>('.netbox-api-select')) {
new APISelect(select);
}
}