mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-18 04:56:29 -06:00
#6372: Fix query issues with & refactor API select
This commit is contained in:
parent
e3008babfe
commit
1661d1f4ca
@ -2077,6 +2077,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
attrs={
|
attrs={
|
||||||
'disabled-indicator': 'device',
|
'disabled-indicator': 'device',
|
||||||
'data-query-param-face': "[\"$face\"]",
|
'data-query-param-face': "[\"$face\"]",
|
||||||
|
# The UI will not sort this element's options.
|
||||||
|
'pre-sorted': ''
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -1,41 +1,21 @@
|
|||||||
import SlimSelect from 'slim-select';
|
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
|
import { readableColor } from 'color2k';
|
||||||
|
import SlimSelect from 'slim-select';
|
||||||
|
import { createToast } from '../bs';
|
||||||
|
import { hasUrl, hasExclusions } from './util';
|
||||||
import {
|
import {
|
||||||
|
isTruthy,
|
||||||
|
hasError,
|
||||||
|
getElement,
|
||||||
getApiData,
|
getApiData,
|
||||||
isApiError,
|
isApiError,
|
||||||
getElements,
|
getElements,
|
||||||
isTruthy,
|
|
||||||
hasError,
|
|
||||||
findFirstAdjacent,
|
findFirstAdjacent,
|
||||||
} from '../util';
|
} from '../util';
|
||||||
import { createToast } from '../bs';
|
|
||||||
import { setOptionStyles, toggle, getDependencyIds, initResetButton } from './util';
|
|
||||||
|
|
||||||
import type { Option } from 'slim-select/dist/data';
|
import type { Option } from 'slim-select/dist/data';
|
||||||
|
|
||||||
type WithUrl = {
|
type QueryFilter = Map<string, string | number>;
|
||||||
'data-url': string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WithExclude = {
|
|
||||||
queryParamExclude: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ReplaceTuple = [RegExp, string];
|
|
||||||
|
|
||||||
type CustomSelect<T extends Record<string, string>> = HTMLSelectElement & T;
|
|
||||||
|
|
||||||
function hasUrl(el: HTMLSelectElement): el is CustomSelect<WithUrl> {
|
|
||||||
const value = el.getAttribute('data-url');
|
|
||||||
return typeof value === 'string' && value !== '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasExclusions(el: HTMLSelectElement): el is CustomSelect<WithExclude> {
|
|
||||||
const exclude = el.getAttribute('data-query-param-exclude');
|
|
||||||
return typeof exclude === 'string' && exclude !== '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const DISABLED_ATTRIBUTES = ['occupied'] as string[];
|
|
||||||
|
|
||||||
// Various one-off patterns to replace in query param keys.
|
// Various one-off patterns to replace in query param keys.
|
||||||
const REPLACE_PATTERNS = [
|
const REPLACE_PATTERNS = [
|
||||||
@ -45,52 +25,295 @@ const REPLACE_PATTERNS = [
|
|||||||
[new RegExp(/tenant_(group)/g), '$1_id'],
|
[new RegExp(/tenant_(group)/g), '$1_id'],
|
||||||
// Append `_id` to any fields
|
// Append `_id` to any fields
|
||||||
[new RegExp(/^([A-Za-z0-9]+)(_id)?$/g), '$1_id'],
|
[new RegExp(/^([A-Za-z0-9]+)(_id)?$/g), '$1_id'],
|
||||||
] as ReplaceTuple[];
|
] as [RegExp, string][];
|
||||||
|
|
||||||
|
// Empty placeholder option.
|
||||||
const PLACEHOLDER = {
|
const PLACEHOLDER = {
|
||||||
value: '',
|
value: '',
|
||||||
text: '',
|
text: '',
|
||||||
placeholder: true,
|
placeholder: true,
|
||||||
} as Option;
|
} as Option;
|
||||||
|
|
||||||
|
// Attributes which if truthy should render the option disabled.
|
||||||
|
const DISABLED_ATTRIBUTES = ['occupied'] as string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve all objects for this object type.
|
* 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.
|
||||||
* @param url API endpoint to query.
|
|
||||||
*
|
|
||||||
* @returns Data parsed into SlimSelect options.
|
|
||||||
*/
|
*/
|
||||||
async function getOptions(
|
class APISelect {
|
||||||
url: string,
|
/**
|
||||||
select: HTMLSelectElement,
|
* Base `<select/>` DOM element.
|
||||||
disabledOptions: string[],
|
*/
|
||||||
): Promise<Option[]> {
|
private readonly base: HTMLSelectElement;
|
||||||
if (url.includes(`{{`)) {
|
|
||||||
return [PLACEHOLDER];
|
/**
|
||||||
|
* 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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
this._options = newOptions;
|
||||||
|
this.slim.setData(newOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all options and reset back to the generic placeholder.
|
||||||
|
*/
|
||||||
|
private resetOptions(): void {
|
||||||
|
this.options = [PLACEHOLDER];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
// 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`.
|
||||||
const selectOptions = Array.from(select.options)
|
const selectOptions = Array.from(this.base.options)
|
||||||
|
.filter(option => option.selected)
|
||||||
.map(option => option.getAttribute('value'))
|
.map(option => option.getAttribute('value'))
|
||||||
.filter(isTruthy);
|
.filter(isTruthy);
|
||||||
|
|
||||||
const data = await getApiData(url);
|
const data = await getApiData(this.queryUrl);
|
||||||
|
|
||||||
if (hasError(data)) {
|
if (hasError(data)) {
|
||||||
if (isApiError(data)) {
|
if (isApiError(data)) {
|
||||||
createToast('danger', data.exception, data.error).show();
|
return this.handleError(data.exception, data.error);
|
||||||
return [PLACEHOLDER];
|
|
||||||
}
|
}
|
||||||
createToast('danger', `Error Fetching Options for field ${select.name}`, data.error).show();
|
return this.handleError(`Error Fetching Options for field '${this.name}'`, data.error);
|
||||||
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 = this.getDisplayName(result);
|
||||||
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;
|
let style, selected, disabled;
|
||||||
@ -114,7 +337,7 @@ async function getOptions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set option to disabled if it is contained within the disabled array.
|
// Set option to disabled if it is contained within the disabled array.
|
||||||
if (selectOptions.some(option => disabledOptions.includes(option))) {
|
if (selectOptions.some(option => this.disabledOptions.includes(option))) {
|
||||||
disabled = true;
|
disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,34 +360,166 @@ async function getOptions(
|
|||||||
|
|
||||||
options.push(option);
|
options.push(option);
|
||||||
}
|
}
|
||||||
return options;
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.base.dispatchEvent(this.loadEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = this.url;
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Find the select element's placeholder text/label.
|
||||||
*/
|
*/
|
||||||
function getPlaceholder(select: HTMLSelectElement): string {
|
private getPlaceholder(): string {
|
||||||
let placeholder = select.name;
|
let placeholder = this.name;
|
||||||
if (select.id) {
|
if (this.base.id) {
|
||||||
const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
|
const label = document.querySelector(`label[for=${this.base.id}]`) as HTMLLabelElement;
|
||||||
|
|
||||||
// Set the placeholder text to the label value, if it exists.
|
// Set the placeholder text to the label value, if it exists.
|
||||||
if (label !== null) {
|
if (label !== null) {
|
||||||
placeholder = `Select ${label.innerText.trim()}`;
|
placeholder = `Select ${label.innerText.trim()}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return placeholder;
|
return placeholder;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find this field's display name.
|
* Find this element's display name.
|
||||||
* @param select
|
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
function getDisplayName(result: APIObjectBase, select: HTMLSelectElement): string {
|
private getDisplayName(result: APIObjectBase): string {
|
||||||
let displayName = result.display;
|
let displayName = result.display;
|
||||||
|
|
||||||
const legacyDisplayProperty = select.getAttribute('display-field');
|
const legacyDisplayProperty = this.base.getAttribute('display-field');
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof displayName === 'undefined' &&
|
typeof displayName === 'undefined' &&
|
||||||
@ -179,201 +534,191 @@ function getDisplayName(result: APIObjectBase, select: HTMLSelectElement): strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
return displayName;
|
return displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize select elements that rely on the NetBox API to build their options.
|
* Get this element's disabled options by value. The `data-query-param-exclude` attribute will
|
||||||
|
* contain a stringified JSON array of option values.
|
||||||
*/
|
*/
|
||||||
export function initApiSelect() {
|
private getDisabledOptions(): string[] {
|
||||||
for (const select of getElements<HTMLSelectElement>('.netbox-api-select')) {
|
|
||||||
const dependencies = getDependencyIds(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 = { limit: 0 } as Record<string, string | number>;
|
|
||||||
|
|
||||||
if (hasUrl(select)) {
|
|
||||||
// Store the original URL, so it can be referred back to as filter-by elements change.
|
|
||||||
// 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') ?? '';
|
|
||||||
|
|
||||||
const placeholder = getPlaceholder(select);
|
|
||||||
|
|
||||||
let disabledOptions = [] as string[];
|
let disabledOptions = [] as string[];
|
||||||
if (hasExclusions(select)) {
|
if (hasExclusions(this.base)) {
|
||||||
try {
|
try {
|
||||||
const exclusions = JSON.parse(
|
const exclusions = JSON.parse(
|
||||||
select.getAttribute('data-query-param-exclude') ?? '[]',
|
this.base.getAttribute('data-query-param-exclude') ?? '[]',
|
||||||
) as string[];
|
) as string[];
|
||||||
disabledOptions = [...disabledOptions, ...exclusions];
|
disabledOptions = [...disabledOptions, ...exclusions];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
console.group(
|
||||||
`Unable to parse data-query-param-exclude value on select element '${select.name}': ${err}`,
|
`Unable to parse data-query-param-exclude value on select element '${this.name}'`,
|
||||||
);
|
);
|
||||||
|
console.warn(err);
|
||||||
|
console.groupEnd();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return disabledOptions;
|
||||||
const instance = new SlimSelect({
|
|
||||||
select,
|
|
||||||
allowDeselect: true,
|
|
||||||
deselectLabel: `<i class="mdi mdi-close-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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disable the element while data has not been loaded.
|
|
||||||
toggle('disable', instance);
|
|
||||||
|
|
||||||
// Don't copy classes from select element to SlimSelect instance.
|
|
||||||
for (const className of select.classList) {
|
|
||||||
instance.slim.container.classList.remove(className);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an element's API URL based on the value of another element upon which this element
|
* Parse the `data-url` attribute to add any Django template variables to `pathValues` as keys
|
||||||
* relies.
|
* 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.
|
||||||
*
|
*
|
||||||
* @param id DOM ID of the other element.
|
* Looks for the DOM attribute `data-query-param-<name of other field>`, which would look like:
|
||||||
*/
|
* `["$<name>"]`
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isTruthy(element.value)) {
|
|
||||||
// Add the dependency's value to the URL query.
|
|
||||||
query[key] = element.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Process each of the dependencies, updating this element's URL or other attributes as
|
|
||||||
// needed.
|
|
||||||
for (const dep of dependencies) {
|
|
||||||
updateQuery(dep);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a valid encoded URL with all query params.
|
|
||||||
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.
|
* If the attribute exists, parse out the raw value. In the above example, this would be `name`.
|
||||||
*/
|
*/
|
||||||
function handleEvent(event: Event) {
|
private getFilteredBy(): void {
|
||||||
const target = event.target as HTMLSelectElement;
|
const pattern = new RegExp(/\[|\]|"|\$/g);
|
||||||
// Update the element's URL after any changes to a dependency.
|
const keyPattern = new RegExp(/data-query-param-/g);
|
||||||
updateQuery(target.id);
|
|
||||||
|
|
||||||
// Disable the element while data is loading.
|
// Extract data attributes.
|
||||||
toggle('disable', instance);
|
const keys = Object.values(this.base.attributes)
|
||||||
// Load new data.
|
.map(v => v.name)
|
||||||
getOptions(url, select, disabledOptions)
|
.filter(v => v.includes('data'));
|
||||||
.then(data => instance.setData(data))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => {
|
|
||||||
// Re-enable the element after data has loaded.
|
|
||||||
toggle('enable', instance);
|
|
||||||
// Inform any event listeners that data has updated.
|
|
||||||
select.dispatchEvent(event);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dep of dependencies) {
|
for (const key of keys) {
|
||||||
const element = document.getElementById(`id_${dep}`);
|
if (key.match(keyPattern) && key !== 'data-query-param-exclude') {
|
||||||
if (element !== null) {
|
const value = this.base.getAttribute(key);
|
||||||
element.addEventListener('change', handleEvent);
|
if (value !== null) {
|
||||||
}
|
|
||||||
select.addEventListener(`netbox.select.onload.${dep}`, handleEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load this element's options from the NetBox API.
|
|
||||||
*/
|
|
||||||
async function loadData(): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const options = await getOptions(url, select, disabledOptions);
|
const parsed = JSON.parse(value) as string | string[];
|
||||||
instance.setData(options);
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.warn(err);
|
||||||
} finally {
|
if (value.match(/^\$.+$/g)) {
|
||||||
setOptionStyles(instance);
|
const replaced = value.replaceAll(pattern, '');
|
||||||
toggle('enable', instance);
|
this.filterParams.set(replaced, '');
|
||||||
select.dispatchEvent(event);
|
} else {
|
||||||
|
this.filterParams.set(key.replaceAll(keyPattern, ''), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete this element's options.
|
* 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.
|
||||||
*/
|
*/
|
||||||
function clearData(): void {
|
private setSlimStyles(): void {
|
||||||
return instance.setData([]);
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if this element is part of collapsible element.
|
/**
|
||||||
const collapse = findFirstAdjacent(select, '.collapse', '.content-container');
|
* Add scoped style elements specific to each SlimSelect option, if the color property exists.
|
||||||
if (collapse !== null) {
|
* As of this writing, this attribute only exist on Tags. The color property is used as the
|
||||||
// If this element is part of a collapsible element, only load the data when the
|
* background color, and a foreground color is detected based on the luminosity of the background
|
||||||
// collapsible element is shown.
|
* color.
|
||||||
// See: https://getbootstrap.com/docs/5.0/components/collapse/#events
|
*/
|
||||||
collapse.addEventListener('show.bs.collapse', loadData);
|
private setOptionStyles(): void {
|
||||||
collapse.addEventListener('hide.bs.collapse', clearData);
|
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 {
|
} else {
|
||||||
// Otherwise, load the data on render.
|
this.slim.setSelected('');
|
||||||
Promise.all([loadData()]);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// Bind event listener to
|
|
||||||
initResetButton(select, 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,
|
|
||||||
// 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';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initApiSelect() {
|
||||||
|
for (const select of getElements<HTMLSelectElement>('.netbox-api-select')) {
|
||||||
|
new APISelect(select);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,207 +1,17 @@
|
|||||||
import { readableColor } from 'color2k';
|
|
||||||
import { findFirstAdjacent } from '../util';
|
|
||||||
|
|
||||||
import type SlimSelect from 'slim-select';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add or remove a class to the SlimSelect element to match Bootstrap .form-select:disabled styles.
|
* Determine if an element has the `data-url` attribute set.
|
||||||
*
|
|
||||||
* @param action `enable` or `disable`
|
|
||||||
* @param instance Instance of SlimSelect
|
|
||||||
*/
|
*/
|
||||||
export function toggle(action: 'enable' | 'disable', instance: SlimSelect): void {
|
export function hasUrl(el: HTMLSelectElement): el is HTMLSelectElement & { 'data-url': string } {
|
||||||
if (action === 'enable') {
|
const value = el.getAttribute('data-url');
|
||||||
if (instance.slim.singleSelected !== null) {
|
return typeof value === 'string' && value !== '';
|
||||||
if (instance.slim.singleSelected.container.hasAttribute('disabled')) {
|
|
||||||
instance.slim.singleSelected.container.removeAttribute('disabled');
|
|
||||||
}
|
|
||||||
} else if (instance.slim.multiSelected !== null) {
|
|
||||||
if (instance.slim.multiSelected.container.hasAttribute('disabled')) {
|
|
||||||
instance.slim.multiSelected.container.removeAttribute('disabled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (action === 'disable') {
|
|
||||||
if (instance.slim.singleSelected !== null) {
|
|
||||||
if (!instance.slim.singleSelected.container.hasAttribute('disabled')) {
|
|
||||||
instance.slim.singleSelected.container.setAttribute('disabled', '');
|
|
||||||
}
|
|
||||||
} else if (instance.slim.multiSelected !== null) {
|
|
||||||
if (!instance.slim.multiSelected.container.hasAttribute('disabled')) {
|
|
||||||
instance.slim.multiSelected.container.setAttribute('disabled', '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add scoped style elements specific to each SlimSelect option, if the color property exists.
|
* Determine if an element has the `data-query-param-exclude` attribute set.
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* @param instance SlimSelect instance with options already set.
|
|
||||||
*/
|
*/
|
||||||
export function setOptionStyles(instance: SlimSelect): void {
|
export function hasExclusions(
|
||||||
const options = instance.data.data;
|
el: HTMLSelectElement,
|
||||||
for (const option of options) {
|
): el is HTMLSelectElement & { 'data-query-param-exclude': string } {
|
||||||
// Only create style elements for options that contain a color attribute.
|
const exclude = el.getAttribute('data-query-param-exclude');
|
||||||
if (
|
return typeof exclude === 'string' && exclude !== '';
|
||||||
'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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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`.
|
|
||||||
*
|
|
||||||
* @param element Element to scan
|
|
||||||
* @returns Map of attributes to values. An empty value indicates a dynamic property that will
|
|
||||||
* be updated later.
|
|
||||||
*/
|
|
||||||
export function getFilteredBy<T extends HTMLElement>(element: T): Map<string, string> {
|
|
||||||
const pattern = new RegExp(/\[|\]|"|\$/g);
|
|
||||||
const keyPattern = new RegExp(/data-query-param-/g);
|
|
||||||
|
|
||||||
// Extract data attributes.
|
|
||||||
const keys = Object.values(element.attributes)
|
|
||||||
.map(v => v.name)
|
|
||||||
.filter(v => v.includes('data'));
|
|
||||||
|
|
||||||
const filterMap = new Map<string, string>();
|
|
||||||
|
|
||||||
// Process the URL attribute in a separate loop so that it comes first.
|
|
||||||
for (const key of keys) {
|
|
||||||
const url = element.getAttribute('data-url');
|
|
||||||
if (key === 'data-url' && url !== null && url.includes(`{{`)) {
|
|
||||||
// If the URL contains a Django/Jinja template variable tag we need to extract the variable
|
|
||||||
// name and consider this a field to monitor for changes.
|
|
||||||
const value = url.match(/\{\{(.+)\}\}/);
|
|
||||||
if (value !== null) {
|
|
||||||
filterMap.set(value[1], '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const key of keys) {
|
|
||||||
if (key.match(keyPattern) && key !== 'data-query-param-exclude') {
|
|
||||||
const value = element.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, '');
|
|
||||||
filterMap.set(replaced, '');
|
|
||||||
} else {
|
|
||||||
filterMap.set(key.replaceAll(keyPattern, ''), item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (parsed.match(/^\$.+$/g)) {
|
|
||||||
const replaced = parsed.replaceAll(pattern, '');
|
|
||||||
filterMap.set(replaced, '');
|
|
||||||
} else {
|
|
||||||
filterMap.set(key.replaceAll(keyPattern, ''), parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(err);
|
|
||||||
if (value.match(/^\$.+$/g)) {
|
|
||||||
const replaced = value.replaceAll(pattern, '');
|
|
||||||
filterMap.set(replaced, '');
|
|
||||||
} else {
|
|
||||||
filterMap.set(key.replaceAll(keyPattern, ''), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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', ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize any adjacent reset buttons so that when clicked, the instance's selected value is cleared.
|
|
||||||
*
|
|
||||||
* @param select Select Element
|
|
||||||
* @param instance SlimSelect Instance
|
|
||||||
*/
|
|
||||||
export function initResetButton(select: HTMLSelectElement, instance: SlimSelect) {
|
|
||||||
const resetButton = findFirstAdjacent<HTMLButtonElement>(select, 'button[data-reset-select');
|
|
||||||
if (resetButton !== null) {
|
|
||||||
resetButton.addEventListener('click', () => {
|
|
||||||
select.value = '';
|
|
||||||
if (select.multiple) {
|
|
||||||
instance.setSelected([]);
|
|
||||||
} else {
|
|
||||||
instance.setSelected('');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user