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; 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", "flatpickr": "4.6.3",
"jquery": "3.5.1", "jquery": "3.5.1",
"jquery-ui": "1.12.1", "jquery-ui": "1.12.1",
"just-debounce-it": "^1.4.0",
"masonry-layout": "^4.2.2", "masonry-layout": "^4.2.2",
"parcel-bundler": "1.12.3", "parcel-bundler": "1.12.3",
"query-string": "^6.14.1", "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 { interface SearchFilterButton extends EventTarget {
dataset: { searchValue: string }; dataset: { searchValue: string };
@ -42,20 +43,21 @@ function initSearchBar() {
* Initialize Interface Table Filter Elements. * Initialize Interface Table Filter Elements.
*/ */
function initInterfaceFilter() { 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. * Filter on-page table by input text.
*/ */
function handleInput(event: Event) { function handleInput(event: Event) {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
// Create a regex pattern from the input search text to match against. // 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. // Each row represents an interface and its attributes.
for (const row of getElements<HTMLTableRowElement>('table > tbody > tr')) { for (const row of rows) {
// The data-name attribute's value contains the interface name.
const name = row.getAttribute('data-name');
// Find the row's checkbox and deselect it, so that it is not accidentally included in form // Find the row's checkbox and deselect it, so that it is not accidentally included in form
// submissions. // submissions.
const checkBox = row.querySelector<HTMLInputElement>('input[type="checkbox"][name="pk"]'); const checkBox = row.querySelector<HTMLInputElement>('input[type="checkbox"][name="pk"]');
@ -63,8 +65,11 @@ function initInterfaceFilter() {
checkBox.checked = false; checkBox.checked = false;
} }
// The data-name attribute's value contains the interface name.
const name = row.getAttribute('data-name');
if (typeof name === 'string') { 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 this row matches the search pattern, but is already hidden, unhide it.
if (row.classList.contains('d-none')) { if (row.classList.contains('d-none')) {
row.classList.remove('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() { export function initSearch() {
for (const func of [initSearchBar, initInterfaceFilter]) { for (const func of [initSearchBar, initTableFilter, initInterfaceFilter]) {
func(); func();
} }
} }

View File

@ -5,6 +5,16 @@ type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
type ReqData = URLSearchParams | Dict | undefined | unknown; type ReqData = URLSearchParams | Dict | undefined | unknown;
type SelectedOption = { name: string; options: string[] }; 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 { export function isApiError(data: Record<string, unknown>): data is APIError {
return 'error' in data && 'exception' in data; 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" json-schema "0.2.3"
verror "1.10.0" 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: kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
version "3.2.2" version "3.2.2"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" 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">
<div class="card-header"> <div class="card-header">
<h5 class="d-inline">Interfaces</h5> <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"> <div class="input-group input-group-sm">
<input type="text" class="form-control interface-filter" placeholder="Filter" title="Filter text (regular expressions supported)" /> <input type="text" class="form-control interface-filter" placeholder="Filter" title="Filter text (regular expressions supported)" />
{% if request.user.is_authenticated %} {% 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 %} {% endif %}
</div> </div>
</div> </div>

View File

@ -7,7 +7,7 @@
{% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %} {% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %}
{% block controls %} {% 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"> <div class="d-flex flex-wrap justify-content-end">
{% block extra_controls %}{% endblock %} {% block extra_controls %}{% endblock %}
{% if permissions.add and 'add' in action_buttons %} {% if permissions.add and 'add' in action_buttons %}
@ -19,19 +19,6 @@
{% if 'export' in action_buttons %} {% if 'export' in action_buttons %}
{% export_button content_type %} {% export_button content_type %}
{% endif %} {% 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>
</div> </div>
{% endblock %} {% endblock %}
@ -39,56 +26,71 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col-9"> <div class="col-9">
{% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %} <div class="card">
{% if permissions.change or permissions.delete %} <div class="card-header">
<form method="post" class="form form-horizontal"> <div class="float-end col-md-2 noprint table-controls">
{% csrf_token %} <div class="input-group input-group-sm">
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" /> <input type="text" class="form-control object-filter" placeholder="Filter" title="Filter text (regular expressions supported)" />
{% if table.paginator.num_pages > 1 %} {% if request.user.is_authenticated and table_config_form %}
<div id="select_all_box" class="hidden card noprint"> <button type="button" class="btn btn-outline-dark btn-sm" data-bs-toggle="modal" data-bs-target="#ObjectTable_config" title="Configure Table">
<div class="card-body"> <i class="mdi mdi-table-eye"></i>
<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
</label>
</div>
<div class="float-end">
{% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
</button> </button>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
{% endif %} <div class="card-body">
{% include table_template|default:'responsive_table.html' %} {% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %}
<div class="float-start noprint bulk-buttons"> {% if permissions.change or permissions.delete %}
{% block bulk_buttons %}{% endblock %} <form method="post" class="form form-horizontal">
{% if bulk_edit_url and permissions.change %} {% csrf_token %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm"> <input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
<i class="bi bi-pencil-fill" aria-hidden="true"></i> Edit Selected {% if table.paginator.num_pages > 1 %}
</button> <div id="select_all_box" class="hidden card noprint">
{% endif %} <div class="card-body">
{% if bulk_delete_url and permissions.delete %} <div class="checkbox-inline">
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm"> <label for="select_all">
<i class="bi bi-trash-fill" aria-hidden="true"></i> Delete Selected <input type="checkbox" id="select_all" name="_all" />
</button> Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> Matching Query
</label>
</div>
<div class="float-end">
{% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
</button>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% include table_template|default:'responsive_table.html' %}
<div class="float-start noprint bulk-buttons">
{% block bulk_buttons %}{% endblock %}
{% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
<i class="bi bi-pencil-fill" aria-hidden="true"></i> Edit Selected
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
<i class="bi bi-trash-fill" aria-hidden="true"></i> Delete Selected
</button>
{% endif %}
</div>
</form>
{% else %}
{% include table_template|default:'responsive_table.html' %}
{% endif %} {% endif %}
{% endwith %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
</div> </div>
</form> </div>
{% else %}
{% include table_template|default:'responsive_table.html' %}
{% endif %}
{% endwith %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
<div class="clearfix"></div>
</div> </div>
{% if filter_form %} {% if filter_form %}
<div class="col-3 noprint"> <div class="col-3 noprint">

View File

@ -9,11 +9,11 @@
<div class="card my-3"> <div class="card my-3">
<div class="card-header"> <div class="card-header">
<h5>Interfaces</h5> <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"> <div class="input-group input-group-sm">
<input type="text" class="form-control interface-filter" placeholder="Filter" title="Filter text (regular expressions supported)" /> <input type="text" class="form-control interface-filter" placeholder="Filter" title="Filter text (regular expressions supported)" />
{% if request.user.is_authenticated %} {% 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 %} {% endif %}
</div> </div>
</div> </div>