From 463ba12997e3d698b956bbd28b1ae2c76b2227da Mon Sep 17 00:00:00 2001 From: Stephan de Wit Date: Mon, 24 Mar 2025 11:26:22 +0100 Subject: [PATCH] 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-` 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 --- .../views/OPNsense/Firewall/filter_rule.volt | 78 +++----- .../mvc/app/views/layouts/default.volt | 2 +- src/opnsense/www/css/jquery.bootgrid.css | 20 +- src/opnsense/www/js/jquery.bootgrid.js | 175 ++++++++++++++---- .../build/css/jquery.bootgrid.css | 20 +- 5 files changed, 209 insertions(+), 86 deletions(-) diff --git a/src/opnsense/mvc/app/views/OPNsense/Firewall/filter_rule.volt b/src/opnsense/mvc/app/views/OPNsense/Firewall/filter_rule.volt index 35c43a9bb..8a7eca58d 100644 --- a/src/opnsense/mvc/app/views/OPNsense/Firewall/filter_rule.volt +++ b/src/opnsense/mvc/app/views/OPNsense/Firewall/filter_rule.volt @@ -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 ''; + }, + evaluations: function (column) { + return ''; + }, + states: function (column) { + return ''; + }, + packets: function (column) { + return ''; + }, + bytes: function (column) { + return ''; + }, + categories: function (column) { + return ''; + } + }, 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(` - - `); - $(this).find('th[data-column-id="evaluations"] .text').html(` - - `); - $(this).find('th[data-column-id="states"] .text').html(` - - `); - $(this).find('th[data-column-id="packets"] .text').html(` - - `); - $(this).find('th[data-column-id="bytes"] .text').html(` - - `); - $(this).find('th[data-column-id="categories"] .text').html(` - - `); - - // 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'); diff --git a/src/opnsense/mvc/app/views/layouts/default.volt b/src/opnsense/mvc/app/views/layouts/default.volt index 64e62f4a9..b4dfd2ae3 100644 --- a/src/opnsense/mvc/app/views/layouts/default.volt +++ b/src/opnsense/mvc/app/views/layouts/default.volt @@ -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, { diff --git a/src/opnsense/www/css/jquery.bootgrid.css b/src/opnsense/www/css/jquery.bootgrid.css index d14740980..73f846f36 100644 --- a/src/opnsense/www/css/jquery.bootgrid.css +++ b/src/opnsense/www/css/jquery.bootgrid.css @@ -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; diff --git a/src/opnsense/www/js/jquery.bootgrid.js b/src/opnsense/www/js/jquery.bootgrid.js index b2ddb45ed..d9aad9e7c 100644 --- a/src/opnsense/www/js/jquery.bootgrid.js +++ b/src/opnsense/www/js/jquery.bootgrid.js @@ -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: '', noResults: "No results found!", refresh: "Refresh", + reset: "Reset", search: "Search" }, @@ -1431,16 +1503,17 @@ Grid.defaults = { actionButton: "", actionDropDown: "
    ", actionDropDownItem: "
  • {{ctx.text}}
  • ", - actionDropDownCheckboxItem: "
  • ", + actionDropDownCheckboxItem: "
  • ", actions: "
    ", body: "", cell: "{{ctx.content}}", footer: "

    ", header: "

    ", - headerCell: "{{ctx.column.text}}{{ctx.icon}}", + headerCell: "{{ctx.column.headerText}}{{ctx.icon}}", icon: "", infos: "
    {{lbl.infos}}
    ", - loading: "{{lbl.loading}}", + loading: "
    {{lbl.loading}}
    ", + emptyBody: " ", noResults: "{{lbl.noResults}}", pagination: "", paginationItem: "
  • {{ctx.text}}
  • ", @@ -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 // ============ diff --git a/src/opnsense/www/themes/opnsense-dark/build/css/jquery.bootgrid.css b/src/opnsense/www/themes/opnsense-dark/build/css/jquery.bootgrid.css index c91be915e..3a2fd5917 100644 --- a/src/opnsense/www/themes/opnsense-dark/build/css/jquery.bootgrid.css +++ b/src/opnsense/www/themes/opnsense-dark/build/css/jquery.bootgrid.css @@ -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;