1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Deprecate collapsible advanced search and re-implement field-based filtering on object views

This commit is contained in:
checktheroads
2021-08-01 21:24:22 -07:00
parent 0b09365d0d
commit 863048cda2
20 changed files with 534 additions and 260 deletions

View File

@@ -2,7 +2,7 @@ import queryString from 'query-string';
import { readableColor } from 'color2k';
import SlimSelect from 'slim-select';
import { createToast } from '../bs';
import { hasUrl, hasExclusions } from './util';
import { hasUrl, hasExclusions, isTrigger } from './util';
import {
isTruthy,
hasError,
@@ -10,6 +10,7 @@ import {
getApiData,
isApiError,
getElements,
createElement,
findFirstAdjacent,
} from '../util';
@@ -17,6 +18,20 @@ import type { Option } from 'slim-select/dist/data';
type QueryFilter = Map<string, string | number | boolean>;
export type Trigger =
/**
* Load data when the select element is opened.
*/
| 'open'
/**
* Load data when the element is loaded.
*/
| 'load'
/**
* Load data when a parent element is uncollapsed.
*/
| 'collapse';
// Various one-off patterns to replace in query param keys.
const REPLACE_PATTERNS = [
// Don't query `termination_a_device=1`, but rather `device=1`.
@@ -57,6 +72,17 @@ class APISelect {
*/
public readonly placeholder: string;
/**
* Event that will initiate the API call to NetBox to load option data. By default, the trigger
* is `'load'`, so data will be fetched when the element renders on the page.
*/
private readonly trigger: Trigger;
/**
* If `true`, a refresh button will be added next to the search/filter `<input/>` element.
*/
private readonly allowRefresh: boolean = true;
/**
* Event to be dispatched when dependent fields' values change.
*/
@@ -153,6 +179,7 @@ class APISelect {
allowDeselect: true,
deselectLabel: `<i class="mdi mdi-close-circle" style="color:currentColor;"></i>`,
placeholder: this.placeholder,
searchPlaceholder: 'Filter',
onChange: () => this.handleSlimChange(),
});
@@ -186,20 +213,44 @@ class APISelect {
// Initialize controlling elements.
this.initResetButton();
// Add the refresh button to the search element.
this.initRefreshButton();
// Add dependency event listeners.
this.addEventListeners();
// Determine if the fetch trigger has been set.
const triggerAttr = this.base.getAttribute('data-fetch-trigger');
// Determine if this element is part of collapsible element.
const collapse = this.base.closest('.content-container .collapse');
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());
if (isTrigger(triggerAttr)) {
this.trigger = triggerAttr;
} else if (collapse !== null) {
this.trigger = 'collapse';
} else {
// Otherwise, load the data on render.
Promise.all([this.loadData()]);
this.trigger = 'load';
}
switch (this.trigger) {
case 'collapse':
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());
}
break;
case 'open':
// If the trigger is 'open', only load API data when the select element is opened.
this.slim.beforeOpen = () => this.loadData();
break;
case 'load':
// Otherwise, load the data immediately.
Promise.all([this.loadData()]);
break;
}
}
@@ -713,21 +764,37 @@ class APISelect {
}
/**
* Initialize any adjacent reset buttons so that when clicked, the instance's selected value is cleared.
* Initialize any adjacent reset buttons so that when clicked, the page is reloaded without
* query parameters.
*/
private initResetButton(): void {
const resetButton = findFirstAdjacent<HTMLButtonElement>(this.base, 'button[data-reset-select');
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('');
}
window.location.assign(window.location.origin + window.location.pathname);
});
}
}
/**
* Add a refresh button to the search container element. When clicked, the API data will be
* reloaded.
*/
private initRefreshButton(): void {
if (this.allowRefresh) {
const refreshButton = createElement(
'button',
{ type: 'button' },
['btn', 'btn-sm', 'btn-ghost-dark'],
[createElement('i', {}, ['mdi', 'mdi-reload'])],
);
refreshButton.addEventListener('click', () => this.loadData());
this.slim.slim.search.container.appendChild(refreshButton);
}
}
}
export function initApiSelect() {

View File

@@ -1,3 +1,5 @@
import type { Trigger } from './api';
/**
* Determine if an element has the `data-url` attribute set.
*/
@@ -15,3 +17,10 @@ export function hasExclusions(
const exclude = el.getAttribute('data-query-param-exclude');
return typeof exclude === 'string' && exclude !== '';
}
/**
* Determine if a trigger value is valid.
*/
export function isTrigger(value: unknown): value is Trigger {
return typeof value === 'string' && ['load', 'open', 'collapse'].includes(value);
}