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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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);
}

View File

@@ -52,7 +52,7 @@
}
* {
transition: $transition-100ms-ease-in-out;
transition: background-color, color 0.1s ease-in-out;
}
.mw-25 {
@@ -302,8 +302,13 @@ span.profile-button .dropdown-menu {
}
}
div#advanced-search-content div.card div.card-body div.col:not(:last-child) {
margin-right: 1rem;
div#advanced-search-content {
&.collapsing {
transition: height 0.1s ease-in-out;
}
div.card div.card-body div.col:not(:last-child) {
margin-right: 1rem;
}
}
body {
@@ -430,6 +435,7 @@ nav.search {
background-color: var(--nbx-body-bg);
// Don't overtake dropdowns
z-index: 999;
justify-content: center;
form button.dropdown-toggle {
border-color: $input-border-color;
font-weight: $input-group-addon-font-weight;

View File

@@ -71,8 +71,8 @@ $spacing-s: $input-padding-x;
border-color: currentColor;
}
}
// Don't show the depth indicator outside of the menu.
.placeholder .depth {
// Don't show the depth indicator outside of the menu.
display: none;
}
span.placeholder > *,
@@ -94,6 +94,11 @@ $spacing-s: $input-padding-x;
.ss-value {
border-radius: $badge-border-radius;
color: var(--nbx-select-value-color);
// Don't show the depth indicator outside of the menu.
.depth {
display: none;
}
}
}
.ss-add {
@@ -133,10 +138,34 @@ $spacing-s: $input-padding-x;
opacity: 0.3;
}
}
&::-webkit-scrollbar {
right: 0;
width: 4px;
&:hover {
opacity: 0.8;
}
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
right: 0;
width: 2px;
background-color: var(--nbx-sidebar-scroll);
}
}
border-bottom-left-radius: $form-select-border-radius;
border-bottom-right-radius: $form-select-border-radius;
.ss-search {
padding-right: $spacer * 0.5;
button {
margin-left: $spacer * 0.75;
}
input[type='search'] {
background-color: $form-select-bg;
color: $input-color;