implement table filtering on generic object list

This commit is contained in:
checktheroads 2021-04-20 12:45:30 -07:00
parent d171e781d2
commit acca69a8a9
18 changed files with 183 additions and 72 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@ -452,3 +452,8 @@ div.card-overlay {
color: $secondary;
}
}
div.card > div.card-header > div.table-controls {
max-width: 25%;
width: 100%;
}

View File

@ -19,6 +19,7 @@
"flatpickr": "4.6.3",
"jquery": "3.5.1",
"jquery-ui": "1.12.1",
"just-debounce-it": "^1.4.0",
"masonry-layout": "^4.2.2",
"parcel-bundler": "1.12.3",
"query-string": "^6.14.1",

View File

@ -1,4 +1,5 @@
import { getElements } from './util';
import debounce from 'just-debounce-it';
import { getElements, getRowValues, findFirstAdjacent } from './util';
interface SearchFilterButton extends EventTarget {
dataset: { searchValue: string };
@ -42,20 +43,21 @@ function initSearchBar() {
* Initialize Interface Table Filter Elements.
*/
function initInterfaceFilter() {
for (const element of getElements<HTMLInputElement>('input.interface-filter')) {
for (const input of getElements<HTMLInputElement>('input.interface-filter')) {
const table = findFirstAdjacent<HTMLTableElement>(input, 'table');
const rows = Array.from(
table?.querySelectorAll<HTMLTableRowElement>('tbody > tr') ?? [],
).filter(r => r !== null);
/**
* Filter on-page table by input text.
*/
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
// Create a regex pattern from the input search text to match against.
const filter = new RegExp(target.value);
const filter = new RegExp(target.value.toLowerCase().trim());
// Each row represents an interface and its attributes.
for (const row of getElements<HTMLTableRowElement>('table > tbody > tr')) {
// The data-name attribute's value contains the interface name.
const name = row.getAttribute('data-name');
for (const row of rows) {
// Find the row's checkbox and deselect it, so that it is not accidentally included in form
// submissions.
const checkBox = row.querySelector<HTMLInputElement>('input[type="checkbox"][name="pk"]');
@ -63,8 +65,11 @@ function initInterfaceFilter() {
checkBox.checked = false;
}
// The data-name attribute's value contains the interface name.
const name = row.getAttribute('data-name');
if (typeof name === 'string') {
if (filter.test(name)) {
if (filter.test(name.toLowerCase().trim())) {
// If this row matches the search pattern, but is already hidden, unhide it.
if (row.classList.contains('d-none')) {
row.classList.remove('d-none');
@ -76,12 +81,57 @@ function initInterfaceFilter() {
}
}
}
element.addEventListener('keyup', handleInput);
input.addEventListener('keyup', debounce(handleInput, 300));
}
}
function initTableFilter() {
for (const input of getElements<HTMLInputElement>('input.object-filter')) {
// Find the first adjacent table element.
const table = findFirstAdjacent<HTMLTableElement>(input, 'table');
// Build a valid array of <tr/> elements that are children of the adjacent table.
const rows = Array.from(
table?.querySelectorAll<HTMLTableRowElement>('tbody > tr') ?? [],
).filter(r => r !== null);
/**
* Filter table rows by matched input text.
* @param event
*/
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
// Create a regex pattern from the input search text to match against.
const filter = new RegExp(target.value.toLowerCase().trim());
for (const row of rows) {
// Find the row's checkbox and deselect it, so that it is not accidentally included in form
// submissions.
const checkBox = row.querySelector<HTMLInputElement>('input[type="checkbox"][name="pk"]');
if (checkBox !== null) {
checkBox.checked = false;
}
// Iterate through each row's cell values
for (const value of getRowValues(row)) {
if (filter.test(value.toLowerCase())) {
// If this row matches the search pattern, but is already hidden, unhide it and stop
// iterating through the rest of the cells.
row.classList.remove('d-none');
break;
} else {
// If none of the cells in this row match the search pattern, hide the row.
row.classList.add('d-none');
}
}
}
}
input.addEventListener('keyup', debounce(handleInput, 300));
}
}
export function initSearch() {
for (const func of [initSearchBar, initInterfaceFilter]) {
for (const func of [initSearchBar, initTableFilter, initInterfaceFilter]) {
func();
}
}

View File

@ -5,6 +5,16 @@ type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
type ReqData = URLSearchParams | Dict | undefined | unknown;
type SelectedOption = { name: string; options: string[] };
// interface TableValue {
// row: {
// element: HTMLTableRowElement;
// };
// cell: {
// element: HTMLTableCellElement;
// value: string;
// };
// }
export function isApiError(data: Record<string, unknown>): data is APIError {
return 'error' in data && 'exception' in data;
}
@ -202,3 +212,41 @@ export function toggleLoader(action: 'show' | 'hide') {
}
}
}
/**
* Get the value of every cell in a table.
* @param table Table Element
*/
export function* getRowValues(table: HTMLTableRowElement): Generator<string> {
for (const element of table.querySelectorAll<HTMLTableCellElement>('td')) {
if (element !== null) {
if (isTruthy(element.innerText) && element.innerText !== '—') {
yield element.innerText.replaceAll(/[\n\r]/g, '').trim();
}
}
}
}
/**
* Recurse upward through an element's siblings until an element matching the query is found.
*
* @param base Base Element
* @param query CSS Query
*/
export function findFirstAdjacent<R extends HTMLElement, B extends Element = Element>(
base: B,
query: string,
): Nullable<R> {
function match<P extends Element | null>(parent: P): Nullable<R> {
if (parent !== null && parent.parentElement !== null) {
for (const child of parent.parentElement.querySelectorAll<R>(query)) {
if (child !== null) {
return child;
}
}
return match(parent.parentElement.parentElement);
}
return null;
}
return match(base);
}

View File

@ -4715,6 +4715,11 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"
just-debounce-it@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/just-debounce-it/-/just-debounce-it-1.4.0.tgz#02c8c95a1bdb70697e72e37fa64ca8689c10e78c"
integrity sha512-D6wp9toCJ77OAL8AvY+fgcNLlR9NC4HKnz6yx6r/IrOFcuDYdqk+P9asMg9nTLYT24Wpu1sT0lukDES6uvQvqA==
kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
version "3.2.2"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"

View File

@ -9,11 +9,11 @@
<div class="card">
<div class="card-header">
<h5 class="d-inline">Interfaces</h5>
<div class="float-end col-md-2 noprint">
<div class="float-end col-md-2 noprint table-controls">
<div class="input-group input-group-sm">
<input type="text" class="form-control interface-filter" placeholder="Filter" title="Filter text (regular expressions supported)" />
{% if request.user.is_authenticated %}
<button type="button" class="btn btn-outline-dark btn-sm" data-bs-toggle="modal" data-bs-target="#DeviceInterfaceTable_config" title="Configure Table"><i class="mdi mdi-cog"></i> Configure</button>
<button type="button" class="btn btn-outline-dark btn-sm" data-bs-toggle="modal" data-bs-target="#DeviceInterfaceTable_config" title="Configure Table"><i class="mdi mdi-table-eye"></i></button>
{% endif %}
</div>
</div>

View File

@ -7,7 +7,7 @@
{% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %}
{% block controls %}
<div class="container mb-2 mx-0">
<div class="controls mb-2 mx-0">
<div class="d-flex flex-wrap justify-content-end">
{% block extra_controls %}{% endblock %}
{% if permissions.add and 'add' in action_buttons %}
@ -19,19 +19,6 @@
{% if 'export' in action_buttons %}
{% export_button content_type %}
{% endif %}
<div class="d-flex flex-shrink-1">
{% if request.user.is_authenticated and table_config_form %}
<button
type="button"
class="btn btn-sm btn-outline-secondary m-1"
data-toggle="modal" data-target="#ObjectTable_config"
title="Configure table"
>
<i class="bi bi-sliders"></i>
</button>
{% endif %}
</div>
</div>
</div>
{% endblock %}
@ -39,6 +26,20 @@
{% block content %}
<div class="row mb-3">
<div class="col-9">
<div class="card">
<div class="card-header">
<div class="float-end col-md-2 noprint table-controls">
<div class="input-group input-group-sm">
<input type="text" class="form-control object-filter" placeholder="Filter" title="Filter text (regular expressions supported)" />
{% if request.user.is_authenticated and table_config_form %}
<button type="button" class="btn btn-outline-dark btn-sm" data-bs-toggle="modal" data-bs-target="#ObjectTable_config" title="Configure Table">
<i class="mdi mdi-table-eye"></i>
</button>
{% endif %}
</div>
</div>
</div>
<div class="card-body">
{% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %}
{% if permissions.change or permissions.delete %}
<form method="post" class="form form-horizontal">
@ -50,7 +51,7 @@
<div class="checkbox-inline">
<label for="select_all">
<input type="checkbox" id="select_all" name="_all" />
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> Matching Query
</label>
</div>
<div class="float-end">
@ -88,7 +89,8 @@
{% endif %}
{% endwith %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
<div class="clearfix"></div>
</div>
</div>
</div>
{% if filter_form %}
<div class="col-3 noprint">

View File

@ -9,11 +9,11 @@
<div class="card my-3">
<div class="card-header">
<h5>Interfaces</h5>
<div class="float-end col-md-2 noprint">
<div class="float-end col-md-2 noprint table-controls">
<div class="input-group input-group-sm">
<input type="text" class="form-control interface-filter" placeholder="Filter" title="Filter text (regular expressions supported)" />
{% if request.user.is_authenticated %}
<button type="button" class="btn btn-outline-dark btn-sm" data-bs-toggle="modal" data-bs-target="#VirtualMachineVMInterfaceTable_config" title="Configure Table"><i class="mdi mdi-cog"></i> Configure</button>
<button type="button" class="btn btn-outline-dark btn-sm" data-bs-toggle="modal" data-bs-target="#VirtualMachineVMInterfaceTable_config" title="Configure Table"><i class="mdi mdi-table-eye"></i></button>
{% endif %}
</div>
</div>