bootgrid: improve UX and extend bootgrid behavior (#8462)

Added options:

- `columnSelectForceReload` (default false). Changes current behavior for all bootgrids (currently adding a new column re-fetches the data, which is unnecessary in most cases). Caches response internally, thereby assuming the data for a newly added column is already present.
- `headerFormatters` object. Can be explicitly set via `data-headerFormatter-<identifier>` or implicitly linked via the row id.
- `setColumns` function (`grid.bootgrid("setColumns", ['colA', 'colB' ...])`). Marks passed columns for addition. Requires either a `reload` or `softreload` to apply.
- `unsetColumns` function (`grid.bootgrid("unsetColumns", ['colA', 'colB' ...])`). Marks passed columns for removal. Requires either a `reload` or `softreload` to apply.
- `softreload` function (`grid.bootgrid("softreload")`).

UX changes:

- `headerFormatters` now makes sure that if column headers require styling, the styling doesn't flash and is applied from the beginning / during reloads.
- The "Loading..." status has been replaced with a transparent overlay containing a spinner. This prevents unnecessary style flashing when data is reloaded, i.e. when scrolling through pages, setting columns, forced refreshes etc.
- Added "reset to defaults" button, resetting the sort, visiblity and rowcount options to the controller defaults (removes them from localstorage).

Fixes https://github.com/opnsense/core/issues/8457
This commit is contained in:
Stephan de Wit 2025-03-24 11:26:22 +01:00 committed by GitHub
parent eef688c3f6
commit 463ba12997
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 209 additions and 86 deletions

View File

@ -57,13 +57,6 @@
// Test if the UUID is valid, used to determine if automation model or internal rule
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// XXX: Clear any stored column visibility settings for the firewall filter page
// Synergizes with the Inspect Button since it unhides certain columns that should be hidden on page load
// Prevents messed up views, we always control the intentional default of the page
Object.keys(localStorage)
.filter(key => key.startsWith("visibleColumns[/ui/firewall/filter/"))
.forEach(key => localStorage.removeItem(key));
// Initialize grid
const grid = $("#{{formGridFilterRule['table_id']}}").UIBootgrid({
search:'/api/firewall/filter/search_rule/',
@ -92,6 +85,30 @@
}
return request;
},
headerFormatters: {
enabled: function (column) { return "" },
icons: function (column) { return "" },
source_port: function (column) { return "{{ lang._('Port') }}" },
destination_port: function (column) { return "{{ lang._('Port') }}" },
interface: function (column) {
return '<i class="fa-solid fa-fw fa-network-wired" data-toggle="tooltip" data-placement="right" title="{{ lang._('Network Interface') }}"></i>';
},
evaluations: function (column) {
return '<i class="fa-solid fa-fw fa-bullseye" data-toggle="tooltip" data-placement="left" title="{{ lang._('Number of rule evaluations') }}"></i>';
},
states: function (column) {
return '<i class="fa-solid fa-fw fa-chart-line" data-toggle="tooltip" data-placement="left" title="{{ lang._('Current active states for this rule') }}"></i>';
},
packets: function (column) {
return '<i class="fa-solid fa-fw fa-box" data-toggle="tooltip" data-placement="left" title="{{ lang._('Total packets matched by this rule') }}"></i>';
},
bytes: function (column) {
return '<i class="fa-solid fa-fw fa-database" data-toggle="tooltip" data-placement="left" title="{{ lang._('Total bytes matched by this rule') }}"></i>';
},
categories: function (column) {
return '<i class="fa-solid fa-fw fa-tag" data-toggle="tooltip" data-placement="left" title="{{ lang._('Categories') }}"></i>';
}
},
formatters:{
// Only show command buttons for rules that have a uuid, internal rules will not have one
commands: function (column, row) {
@ -452,35 +469,8 @@
});
grid.on("loaded.rs.jquery.bootgrid", function () {
// XXX: Replace these labels to save some space in the grid
// This is a workaround, to change labels in the grid, but NOT in the grid selection dropdown
$(this).find('th[data-column-id="enabled"] .text').text("");
$(this).find('th[data-column-id="icons"] .text').text("");
$(this).find('th[data-column-id="source_port"] .text').text("{{ lang._('Port') }}");
$(this).find('th[data-column-id="destination_port"] .text').text("{{ lang._('Port') }}");
$(this).find('th[data-column-id="interface"] .text').html(`
<i class="fa-solid fa-fw fa-network-wired" data-toggle="tooltip" data-placement="right" title="{{ lang._('Network Interface') }}"></i>
`);
$(this).find('th[data-column-id="evaluations"] .text').html(`
<i class="fa-solid fa-fw fa-bullseye" data-toggle="tooltip" data-placement="left" title="{{ lang._('Number of rule evaluations') }}"></i>
`);
$(this).find('th[data-column-id="states"] .text').html(`
<i class="fa-solid fa-fw fa-chart-line" data-toggle="tooltip" data-placement="left" title="{{ lang._('Current active states for this rule') }}"></i>
`);
$(this).find('th[data-column-id="packets"] .text').html(`
<i class="fa-solid fa-fw fa-box" data-toggle="tooltip" data-placement="left" title="{{ lang._('Total packets matched by this rule') }}"></i>
`);
$(this).find('th[data-column-id="bytes"] .text').html(`
<i class="fa-solid fa-fw fa-database" data-toggle="tooltip" data-placement="left" title="{{ lang._('Total bytes matched by this rule') }}"></i>
`);
$(this).find('th[data-column-id="categories"] .text').html(`
<i class="fa-solid fa-fw fa-tag" data-toggle="tooltip" data-placement="left" title="{{ lang._('Categories') }}"></i>
`);
// Initialize tooltips
grid.on("loaded.rs.jquery.bootgrid", function() {
$('[data-toggle="tooltip"]').tooltip();
});
/* for performance reasons, only load catagories on page load */
@ -556,23 +546,11 @@
$("#internal_rule_selector").detach().insertAfter("#type_filter_container");
$('#all_rules_checkbox').change(function(){
grid.bootgrid('reload');
const isChecked = $('#all_rules_checkbox').is(':checked');
grid.bootgrid(isChecked ? "setColumns" : "unsetColumns", ['evaluations', 'states', 'packets', 'bytes']);
grid.bootgrid("reload");
});
/* XXX: needs fix if we want to show the inspect columns on button press */
// Once the grid has reloaded, update the specified checkboxes
// grid.on("loaded.rs.jquery.bootgrid", function () {
// const isChecked = $('#all_rules_checkbox').is(':checked');
// const checkboxes = ['evaluations', 'states', 'packets', 'bytes'];
// checkboxes.forEach(name => {
// const $checkbox = $('input[name="' + name + '"].dropdown-item-checkbox');
// if ($checkbox.length && $checkbox.prop('checked') !== isChecked) {
// $checkbox.click();
// }
// });
// });
$('#all_rules_button').click(function(){
let $checkbox = $('#all_rules_checkbox');

View File

@ -310,9 +310,9 @@
$.extend(jQuery.fn.bootgrid.prototype.constructor.Constructor.defaults.labels, {
all: "{{ lang._('All') }}",
infos: "{{ lang._('Showing %s to %s of %s entries') | format('{{ctx.start}}','{{ctx.end}}','{{ctx.total}}') }}",
loading: "{{ lang._('Loading...') }}",
noResults: "{{ lang._('No results found!') }}",
refresh: "{{ lang._('Refresh') }}",
reset: "{{ lang._('Reset to defaults') }}",
search: "{{ lang._('Search') }}"
});
$.extend(jQuery.fn.selectpicker.Constructor.DEFAULTS, {

View File

@ -115,11 +115,29 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.bootgrid-table td.loading,
.bootgrid-table td.no-results {
background: #fff;
text-align: center;
}
.bootgrid-table tbody {
position: relative;
}
.bootgrid-table tbody > .overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
}
.overlay > i {
font-size: 20px;
color: black;
}
.bootgrid-table th.select-cell,
.bootgrid-table td.select-cell {
text-align: center;

View File

@ -124,6 +124,10 @@ function loadColumns()
headerAlign: data.headerAlign || "left",
cssClass: data.cssClass || "",
headerCssClass: data.headerCssClass || "",
headerFormatter: that.options.headerFormatters[data.headerformatter] || /* explicitly defined through data-headerFormatter */
!(Object.getOwnPropertyNames(Object.prototype).includes(data.columnId)) ? /* reserved keywords */
that.options.headerFormatters[data.columnId] : /* implicitly defined through data-columnId */
null, /* no header formatter present */
formatter: that.options.formatters[data.formatter] || null,
order: !sorted ?
(sortingStorage === null ? (data.order === "asc" || data.order === "desc" ? data.order : null) :
@ -133,6 +137,8 @@ function loadColumns()
sortable: !(data.sortable === false), // default: true
visible: visibilityStorage === null ? !(data.visible === false) : (visibilityStorage === 'true'), // default: true
visibleInSelection: !(data.visibleInSelection === false), // default: true
setStaged: false,
unsetStaged: false,
width: ($.isNumeric(data.width)) ? data.width + "px" :
(typeof(data.width) === "string") ? data.width : null
};
@ -168,7 +174,47 @@ response = {
}
*/
function loadData()
function update(rows, total)
{
var that = this;
that.currentRows = rows;
setTotals.call(that, total);
if (!that.options.keepSelection)
{
that.selectedRows = [];
}
var shouldRenderHeader = false;
$.each(that.columns, function (i, column) {
var checkbox = $('#' + that.element.attr('id') + '_' + column.id);
if (column.setStaged) {
column.visible = shouldRenderHeader = true;
checkbox.prop('checked', true);
column.setStaged = false;
}
if (column.unsetStaged) {
column.visible = column.unsetStaged = false;
checkbox.prop('checked', false);
shouldRenderHeader = true;
}
});
if (shouldRenderHeader) {
/* multiple columns have been set/unset prior to this reload */
renderTableHeader.call(that);
}
renderRows.call(that, rows);
renderInfos.call(that);
renderPagination.call(that);
that.element._bgBusyAria(false).trigger("loaded" + namespace);
}
function loadData(soft=false)
{
var that = this;
@ -193,24 +239,7 @@ function loadData()
return false;
}
function update(rows, total)
{
that.currentRows = rows;
setTotals.call(that, total);
if (!that.options.keepSelection)
{
that.selectedRows = [];
}
renderRows.call(that, rows);
renderInfos.call(that);
renderPagination.call(that);
that.element._bgBusyAria(false).trigger("loaded" + namespace);
}
if (this.options.ajax)
if (this.options.ajax && !soft)
{
var request = getRequest.call(this),
url = getUrl.call(this);
@ -241,7 +270,8 @@ function loadData()
response = that.options.responseHandler(response);
that.current = response.current;
update(response.rows, response.total);
that.cachedResponse = response;
update.call(that, response.rows, response.total);
},
error: function (jqXHR, textStatus, errorThrown)
{
@ -269,7 +299,7 @@ function loadData()
// todo: improve the following comment
// setTimeout decouples the initialization so that adding event handlers happens before
window.setTimeout(function () { update(rows, total); }, 10);
window.setTimeout(function () { update.call(that, rows, total); }, 10);
}
}
@ -320,6 +350,11 @@ function prepareTable()
this.element.append(tpl.body);
}
// on initial load, make sure the loading template has room, so insert an empty row here
this.element.children("tbody").first().append(
tpl.emptyBody.resolve(getParams.call(this, {columns: this.columns.where(isVisible).length}))
)
if (this.options.navigation & 1)
{
this.header = $(tpl.header.resolve(getParams.call(this, { id: this.element._bgId() + "-header" })));
@ -369,6 +404,25 @@ function renderActions()
// Column selection
renderColumnSelection.call(this, actions);
// Reset button
if (this.options.resetButton) {
var resetIcon = tpl.icon.resolve(getParams.call(this, { iconCss: css.iconReset }))
var reset = $(tpl.actionButton.resolve(getParams.call(this, {
content: resetIcon, text: this.options.labels.reset
}))).on("click" + namespace, function (e) {
e.stopPropagation();
Object.keys(localStorage)
.filter(key =>
key.startsWith(`visibleColumns[${that.uid}`) ||
key.startsWith(`rowCount[${that.uid}`) ||
key.startsWith(`sortColumns[${that.uid}`)
)
.forEach(key => localStorage.removeItem(key));
location.reload();
})
actions.append(reset);
}
replacePlaceHolder.call(this, actionItems, actions);
}
}
@ -392,7 +446,7 @@ function renderColumnSelection(actions)
if (column.visibleInSelection)
{
var item = $(tpl.actionDropDownCheckboxItem.resolve(getParams.call(that,
{ name: column.id, label: column.text, checked: column.visible })))
{ name: column.id, label: column.text, checked: column.visible, id: that.element.attr('id') + '_' + column.id })))
.on("click" + namespace, selector, function (e)
{
e.stopPropagation();
@ -409,7 +463,8 @@ function renderColumnSelection(actions)
that.element.find("tbody").empty(); // Fixes an column visualization bug
renderTableHeader.call(that);
loadData.call(that);
that.columnSelectForceReload ? loadData.call(that)
: update.call(that, that.cachedResponse.rows, that.cachedResponse.total);
}
});
dropDown.find(getCssSelector(css.dropDownMenuItems)).append(item);
@ -797,6 +852,9 @@ function renderTableHeader()
icon = tpl.icon.resolve(getParams.call(that, { iconCss: iconCss })),
align = column.headerAlign,
cssClass = (column.headerCssClass.length > 0) ? " " + column.headerCssClass : "";
column.headerText = ($.isFunction(column.headerFormatter)) ?
column.headerFormatter.call(that, column) :
column.text;
html += tpl.headerCell.resolve(getParams.call(that, {
column: column, icon: icon, sortable: sorting && column.sortable && css.sortable || "",
css: ((align === "right") ? css.right : (align === "center") ?
@ -925,11 +983,7 @@ function showLoading()
{
count = count + 1;
}
tbody.html(tpl.loading.resolve(getParams.call(that, { columns: count })));
if (that.rowCount !== -1 && padding > 0)
{
tbody.find("tr > td").css("padding", "20px 0 " + padding + "px");
}
tbody.append(tpl.loading.resolve(getParams.call(that, { columns: count })));
}
}, 250);
}
@ -1008,6 +1062,11 @@ var Grid = function(element, options)
this.sortDictionary = {};
this.total = 0;
this.totalPages = 0;
this.columnSelectForceReload = this.options.columnSelectForceReload;
this.cachedResponse = {
rows: [],
total: 0
};
this.cachedParams = {
lbl: this.options.labels,
css: this.options.css,
@ -1038,6 +1097,7 @@ Grid.defaults = {
navigation: 3, // it's a flag: 0 = none, 1 = top, 2 = bottom, 3 = both (top and bottom)
padding: 2, // page padding (pagination)
columnSelection: true,
columnSelectForceReload: false, // force data fetch on column select
rowCount: [10, 25, 50, -1], // rows per page int or array of int (-1 represents "All")
/**
@ -1088,6 +1148,7 @@ Grid.defaults = {
highlightRows: false, // highlights new rows (find the page of the first new row)
sorting: true,
multiSort: false,
resetButton: true,
/**
* General search settings to configure the search field behaviour.
@ -1309,6 +1370,7 @@ Grid.defaults = {
iconColumns: "fa-list",
iconDown: "fa-chevron-down",
iconRefresh: "fa-arrows-rotate",
iconReset: "fa-share-square",
iconSearch: "fa-magnifying-glass",
iconUp: "fa-chevron-up",
infos: "infos", // must be a unique class name or constellation of class names within the header and footer,
@ -1348,6 +1410,15 @@ Grid.defaults = {
table: "bootgrid-table table"
},
/**
* A dictionary of formatters for the column headers.
*
* @property formatters
* @type Object
* @for defaults
**/
headerFormatters: {},
/**
* A dictionary of formatters.
*
@ -1368,9 +1439,10 @@ Grid.defaults = {
labels: {
all: "All",
infos: "Showing {{ctx.start}} to {{ctx.end}} of {{ctx.total}} entries",
loading: "Loading...",
loading: '<i class="fa fa-spinner fa-spin"></i>',
noResults: "No results found!",
refresh: "Refresh",
reset: "Reset",
search: "Search"
},
@ -1431,16 +1503,17 @@ Grid.defaults = {
actionButton: "<button class=\"btn btn-default\" type=\"button\" title=\"{{ctx.text}}\">{{ctx.content}}</button>",
actionDropDown: "<div class=\"{{css.dropDownMenu}}\"><button class=\"btn btn-default dropdown-toggle\" type=\"button\" data-toggle=\"dropdown\"><span class=\"{{css.dropDownMenuText}}\">{{ctx.content}}</span> <span class=\"caret\"></span></button><ul class=\"{{css.dropDownMenuItems}}\" role=\"menu\"></ul></div>",
actionDropDownItem: "<li><a data-action=\"{{ctx.action}}\" class=\"{{css.dropDownItem}} {{css.dropDownItemButton}}\">{{ctx.text}}</a></li>",
actionDropDownCheckboxItem: "<li><label class=\"{{css.dropDownItem}}\"><input name=\"{{ctx.name}}\" type=\"checkbox\" value=\"1\" class=\"{{css.dropDownItemCheckbox}}\" {{ctx.checked}} /> {{ctx.label}}</label></li>",
actionDropDownCheckboxItem: "<li><label class=\"{{css.dropDownItem}}\"><input id=\"{{ctx.id}}\" name=\"{{ctx.name}}\" type=\"checkbox\" value=\"1\" class=\"{{css.dropDownItemCheckbox}}\" {{ctx.checked}} /> {{ctx.label}}</label></li>",
actions: "<div class=\"{{css.actions}}\"></div>",
body: "<tbody></tbody>",
cell: "<td class=\"{{ctx.css}}\" style=\"{{ctx.style}}\">{{ctx.content}}</td>",
footer: "<div id=\"{{ctx.id}}\" class=\"{{css.footer}}\"><div class=\"row\"><div class=\"col-sm-6\"><p class=\"{{css.pagination}}\"></p></div><div class=\"col-sm-6 infoBar\"><p class=\"{{css.infos}}\"></p></div></div></div>",
header: "<div id=\"{{ctx.id}}\" class=\"{{css.header}}\"><div class=\"row\"><div class=\"col-sm-12 actionBar\"><p class=\"{{css.search}}\"></p><p class=\"{{css.actions}}\"></p></div></div></div>",
headerCell: "<th data-column-id=\"{{ctx.column.id}}\" class=\"{{ctx.css}}\" style=\"{{ctx.style}}\"><a href=\"javascript:void(0);\" class=\"{{css.columnHeaderAnchor}} {{ctx.sortable}}\"><span class=\"{{css.columnHeaderText}}\">{{ctx.column.text}}</span>{{ctx.icon}}</a></th>",
headerCell: "<th data-column-id=\"{{ctx.column.id}}\" class=\"{{ctx.css}}\" style=\"{{ctx.style}}\"><a href=\"javascript:void(0);\" class=\"{{css.columnHeaderAnchor}} {{ctx.sortable}}\"><span class=\"{{css.columnHeaderText}}\">{{ctx.column.headerText}}</span>{{ctx.icon}}</a></th>",
icon: "<span class=\"{{css.icon}} {{ctx.iconCss}}\"></span>",
infos: "<div class=\"{{css.infos}}\">{{lbl.infos}}</div>",
loading: "<tr><td colspan=\"{{ctx.columns}}\" class=\"loading\">{{lbl.loading}}</td></tr>",
loading: "<div class=\"overlay\">{{lbl.loading}}</div>",
emptyBody: "<tr><td colspan=\"{{ctx.columns}}\">&nbsp;</td></tr>",
noResults: "<tr><td colspan=\"{{ctx.columns}}\" class=\"no-results\">{{lbl.noResults}}</td></tr>",
pagination: "<ul class=\"{{css.pagination}}\"></ul>",
paginationItem: "<li class=\"{{ctx.css}}\"><a data-page=\"{{ctx.page}}\" class=\"{{css.paginationButton}}\">{{ctx.text}}</a></li>",
@ -1550,6 +1623,14 @@ Grid.prototype.reload = function()
return this;
};
Grid.prototype.softreload = function()
{
// modifies table structure without data refresh
loadData.call(this, true);
return this;
};
/**
* Removes rows by ids. Removes selected rows if no ids are provided.
*
@ -1781,6 +1862,34 @@ Grid.prototype.getColumnSettings = function()
return $.merge([], this.columns);
};
/**
* Sets a list of the column settings.
* This method applies only to the first grid instance.
*
* @method setColumnSettings
* @param {Array} An array of columns identifiers
* @chainable
**/
Grid.prototype.setColumns = function(cols)
{
this.columns.forEach(col => {
if (cols.includes(col.id)) {
col.setStaged = true;
}
})
return this;
};
Grid.prototype.unsetColumns = function(cols)
{
this.columns.forEach(col => {
if (cols.includes(col.id)) {
col.unsetStaged = true;
}
})
return this;
};
/**
* Gets the current page index.
* This method returns only for the first grid instance a value.
@ -1891,7 +2000,7 @@ Grid.prototype.getTotalPageCount = function()
Grid.prototype.getTotalRowCount = function()
{
return this.total;
};
};
// GRID COMMON TYPE EXTENSIONS
// ============

View File

@ -114,10 +114,28 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.bootgrid-table td.loading,
.bootgrid-table td.no-results {
text-align: center;
}
.bootgrid-table tbody {
position: relative;
}
.bootgrid-table tbody > .overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
}
.overlay > i {
font-size: 20px;
color: white;
}
.bootgrid-table th.select-cell,
.bootgrid-table td.select-cell {
text-align: center;