feature: Search box on the modern filter bar + null-safe tablesorter trim (#8194)

* Surface the Search box on the modern filter bar

Route the Search textbox (filter/rfilter) onto the modern filter bar, just right of the Rows selector, instead of only inside the Edit dialog. Pressing Enter in it applies the filter via the page's configured change function (filter=/rfilter= as appropriate), bound with event delegation so it survives ajax content reloads. field_is_bar() now keeps the search textbox on the bar and it is ordered Rows, Search, Refresh.

* Null-safe tablesorter $.trim rewrite in the sync-js transform

The jQuery 4 compatibility rewrite turned \$.trim(x) into (x).trim(), which throws when a custom textExtraction/parser returns a non-string or null (jQuery coerced values and mapped nullish to ''). Rewrite to String((x) ?? '').trim() - the inner parens keep || expressions from forming an illegal unparenthesized 'a || b ?? ''' - and regenerate include/js/jquery.tablesorter.js. Updates the sync-js fixture test accordingly.

* Add changelog entries for the modern-bar Search and tablesorter trim fix

* Add a Clear glyph button to the modern filter bar

Place an eraser (ti ti-eraser) Clear button just right of Refresh on the modern bar, wired to the page's clear action, so the filter can be reset without opening the Edit dialog. The eraser matches the timespan/generic Clear glyph and stays visually distinct from Delete's trash glyph.

* Give bar-routed Search a persistent label when a page omits friendly_name

The log viewer (clog) defines rfilter without a friendly_name because it shared the adjacent matches field's Search label. Routed onto the modern bar it would show only the placeholder, which disappears after entry. Default a bar-routed filter/rfilter without a friendly_name to Search, scoped to the modern bar so legacy pages are unchanged.

* Apply the filter when the search glyph is clicked

The ti ti-search magnifying glass added after filter/rfilter inputs was decorative. A delegated click now submits the input's form, or on the modern bar (no form) triggers Enter, so clicking it applies the filter.

* Use the legacy filter on Console > Management > Sites

The Sites list gains nothing from saved layouts; set render_layouts = false so it renders the classic legacy filter.

* Restore Search in the Edit dialog and glyph-ify Purge

Mirror the bar Search into the Edit dialog (synced to the canonical bar input on open and on edit) so a saved layout's search term is visible and editable there without duplicating the input id; the bar stays the source of truth and nothing persists to a layout until it is saved. Also force a ti ti-flame glyph for the page-action Purge button.

* Order Search before Rows on the modern bar to match legacy filters

* Narrow the modern-bar regex Search to the normal filter width

The rfilter (regex Search) input on the modern bar renders at size=55, much wider than the size=30 normal filter. Cap it at 30ch via .cactiFilterModernBar input#rfilter in every theme except Midwinter, which the user asked to leave as-is.

* Document the modern-bar Clear, Purge glyph, dialog Search, and Sites legacy filter in the changelog

* Glyph-ify the clog Rotate button

Force a ti ti-rotate glyph for the page-action Rotate button (clog log rotation), matching the Purge/Save/Clear glyph treatment.

---------

Co-authored-by: TheWitness <TheWitness@users.noreply.github.com>
This commit is contained in:
TheWitnessandTheWitness authored and GitHub committed 2026-10-06 13:56:15 -04:00
1 parent 95b2697326
commit 4298f2ae33
18 files changed
+152 -20

No files matched your search

+1 -1
View File
@@ -45,7 +45,7 @@ export const assetMap = Object.freeze({
// files. No-ops once the upstream fixes are released and the pin is bumped.
const tablesorterJquery4 = content => content
.replace(/\$\.isFunction\(\s*([^()]+?)\s*\)/g, "typeof $1 === 'function'")
.replace(/\$\.trim\(((?:[^()]|\([^()]*\))*)\)/g, '($1).trim()')
.replace(/\$\.trim\(((?:[^()]|\([^()]*\))*)\)/g, "String(($1) ?? '').trim()")
.replace(/\$\.type\(\s*([^()]+?)\s*\)\s*===\s*'string'/g, "typeof $1 === 'string'")
.replace(/\$\.type\(\s*([^()]+?)\s*\)\s*===\s*'object'/g, '$$.isPlainObject($1)')
.replace(/\$\.isWindow\(\s*([^()]+?)\s*\)/g, '($1 != null && $1 === $1.window)')
+1
View File
@@ -0,0 +1 @@
Add a Search box and a Clear button to the modern filter bar (Search before Rows to match the legacy filters, Clear to the right of Refresh), apply the filter on Enter or by clicking the search glyph, mirror the Search into the Edit dialog so saved layouts carry and display its value, render the Purge action as a glyph, narrow the regex search box to the normal filter width, and use the legacy filter on the Sites management page
+1
View File
@@ -0,0 +1 @@
Make the tablesorter $.trim rewrite null-safe so custom textExtraction or parser callbacks returning non-string or null values no longer throw
+12 -12
View File
@@ -830,18 +830,18 @@
// check data-attribute first when set to 'basic'; don't use node.innerText - it's really slow!
// http://www.kellegous.com/j/2013/02/27/innertext-vs-textcontent/
if ( extract === 'basic' && typeof ( tmp = $node.attr( c.textAttribute ) ) !== 'undefined' ) {
return ( tmp ).trim();
return String(( tmp ) ?? '').trim();
}
return ( node.textContent || $node.text() ).trim();
return String(( node.textContent || $node.text() ) ?? '').trim();
} else {
if ( typeof extract === 'function' ) {
return ( extract( $node[ 0 ], c.table, cellIndex ) ).trim();
return String(( extract( $node[ 0 ], c.table, cellIndex ) ) ?? '').trim();
} else if ( typeof ( tmp = ts.getColumnData( c.table, extract, cellIndex ) ) === 'function' ) {
return ( tmp( $node[ 0 ], c.table, cellIndex ) ).trim();
return String(( tmp( $node[ 0 ], c.table, cellIndex ) ) ?? '').trim();
}
}
// fallback
return ( $node[ 0 ].textContent || $node.text() ).trim();
return String(( $node[ 0 ].textContent || $node.text() ) ?? '').trim();
},
// centralized function to extract/parse cell contents
@@ -1190,7 +1190,7 @@
tmp = $header.hasClass( ts.css.sortAsc ) ?
'sortAsc' :
$header.hasClass( ts.css.sortDesc ) ? 'sortDesc' : 'sortNone',
txt = ( $header.text() ).trim() + ': ' + ts.language[ tmp ];
txt = String(( $header.text() ) ?? '').trim() + ': ' + ts.language[ tmp ];
if ( $header.hasClass( 'sorter-false' ) || nextSort === false ) {
txt += ts.language.sortDisabled;
} else {
@@ -2268,7 +2268,7 @@
}
num = parseFloat( str );
// return the text instead of zero
return isNaN( num ) ? ( str ).trim() : num;
return isNaN( num ) ? String(( str ) ?? '').trim() : num;
},
isDigit : function( str ) {
@@ -2409,7 +2409,7 @@
// include sorter class name 'sorter-text', etc; now works with 'sorter-my-custom-parser'
val = cl4ss.match( new RegExp( '\\s' + key + '-([\\w-]+)' ) )[ 1 ] || '';
}
return ( val ).trim();
return String(( val ) ?? '').trim();
},
getColumnData : function( table, obj, indx, getCell, $headers ) {
@@ -2672,7 +2672,7 @@
format : function( str, table ) {
var c = table.config;
if ( str ) {
str = ( c.ignoreCase ? str.toLocaleLowerCase() : str ).trim();
str = String(( c.ignoreCase ? str.toLocaleLowerCase() : str ) ?? '').trim();
str = c.sortLocaleCompare ? ts.replaceAccents( str ) : str;
}
return str;
@@ -2689,7 +2689,7 @@
format : function( str, table ) {
var num = ts.formatFloat( ( str || '' ).replace( ts.regex.nondigit, '' ), table );
return str && typeof num === 'number' ? num :
str ? ( table.config.ignoreCase ? str.toLocaleLowerCase() : str ).trim() : str;
str ? String(( table.config.ignoreCase ? str.toLocaleLowerCase() : str ) ?? '').trim() : str;
},
type : 'numeric'
});
@@ -2706,7 +2706,7 @@
format : function( str, table ) {
var num = ts.formatFloat( ( str || '' ).replace( ts.regex.nondigit, '' ), table );
return str && typeof num === 'number' ? num :
str ? ( table.config.ignoreCase ? str.toLocaleLowerCase() : str ).trim() : str;
str ? String(( table.config.ignoreCase ? str.toLocaleLowerCase() : str ) ?? '').trim() : str;
},
type : 'numeric'
});
@@ -2721,7 +2721,7 @@
return ts.regex.urlProtocolTest.test( str );
},
format : function( str ) {
return str ? ( str.replace( ts.regex.urlProtocolReplace, '' ) ).trim() : str;
return str ? String(( str.replace( ts.regex.urlProtocolReplace, '' ) ) ?? '').trim() : str;
},
type : 'text'
});
+17
View File
@@ -3497,6 +3497,23 @@ function setupSpecialKeys() {
} else {
$('#filter, #rfilter').prop('size', '15');
}
// Clicking the search glyph applies the filter: submit its form, or on the modern bar (no form) trigger Enter.
$(document).off('click.cactiSearchIcon').on('click.cactiSearchIcon', 'i.ti.ti-search.filter', function () {
var input = $(this).prev('input').get(0);
if (!input) {
return;
}
if (input.form) {
if (typeof input.form.requestSubmit === 'function') {
input.form.requestSubmit();
} else {
$(input.form).trigger('submit');
}
} else {
$(input).trigger($.Event('keydown', { keyCode: 13, which: 13, key: 'Enter' }));
}
});
}
/**
+5
View File
@@ -1277,6 +1277,11 @@ legend {
vertical-align: middle;
}
/* Narrow the regex Search box on the modern bar to the normal filter width. */
.cactiFilterModernBar input#rfilter {
width: 30ch;
}
.icon-rotate {
display: inline-block;
animation: fa-spin 1.5s linear infinite;
+5
View File
@@ -1283,6 +1283,11 @@ legend {
vertical-align: middle;
}
/* Narrow the regex Search box on the modern bar to the normal filter width. */
.cactiFilterModernBar input#rfilter {
width: 30ch;
}
.icon-rotate {
display: inline-block;
animation: fa-spin 1.5s linear infinite;
+5
View File
@@ -1468,6 +1468,11 @@ legend {
vertical-align: middle;
}
/* Narrow the regex Search box on the modern bar to the normal filter width. */
.cactiFilterModernBar input#rfilter {
width: 30ch;
}
.icon-rotate {
display: inline-block;
animation: fa-spin 1.5s linear infinite;
+5
View File
@@ -1320,6 +1320,11 @@ legend {
vertical-align: middle;
}
/* Narrow the regex Search box on the modern bar to the normal filter width. */
.cactiFilterModernBar input#rfilter {
width: 30ch;
}
.icon-rotate {
display: inline-block;
animation: fa-spin 1.5s linear infinite;
+5
View File
@@ -1491,6 +1491,11 @@ legend {
vertical-align: middle;
}
/* Narrow the regex Search box on the modern bar to the normal filter width. */
.cactiFilterModernBar input#rfilter {
width: 30ch;
}
.icon-rotate {
display: inline-block;
animation: fa-spin 1.5s linear infinite;
+5
View File
@@ -1475,6 +1475,11 @@ tr>td.textInfo.right {
vertical-align: middle;
}
/* Narrow the regex Search box on the modern bar to the normal filter width. */
.cactiFilterModernBar input#rfilter {
width: 30ch;
}
.icon-rotate {
display: inline-block;
animation: fa-spin 1.5s linear infinite;
+5
View File
@@ -1308,6 +1308,11 @@ legend {
vertical-align: middle;
}
/* Narrow the regex Search box on the modern bar to the normal filter width. */
.cactiFilterModernBar input#rfilter {
width: 30ch;
}
.icon-rotate {
display: inline-block;
animation: fa-spin 1.5s linear infinite;
+5
View File
@@ -1295,6 +1295,11 @@ legend {
vertical-align: middle;
}
/* Narrow the regex Search box on the modern bar to the normal filter width. */
.cactiFilterModernBar input#rfilter {
width: 30ch;
}
.icon-rotate {
display: inline-block;
animation: fa-spin 1.5s linear infinite;
+5
View File
@@ -1512,6 +1512,11 @@ table.cactiTable {
vertical-align: middle;
}
/* Narrow the regex Search box on the modern bar to the normal filter width. */
.cactiFilterModernBar input#rfilter {
width: 30ch;
}
.icon-rotate {
display: inline-block;
animation: fa-spin 1.5s linear infinite;
+53 -3
View File
@@ -451,7 +451,13 @@ class CactiTableFilter {
* @return bool
*/
private function field_is_bar(string $field_name, array $field_array) : bool {
return ($field_array['method'] ?? '') === 'timespan' || $field_name === 'refresh' || $field_name === 'rows';
$method = $field_array['method'] ?? '';
// Search stays on the bar (right of Rows) so it is reachable without opening the Edit dialog.
return $method === 'timespan'
|| $field_name === 'refresh'
|| $field_name === 'rows'
|| ($method === 'textbox' && ($field_name === 'filter' || $field_name === 'rfilter'));
}
/**
@@ -502,6 +508,16 @@ class CactiTableFilter {
$field_array['glyph'] = 'ti ti-device-floppy';
}
// The Purge action renders as a flame glyph button.
if ($field_name === 'purge' && ($field_array['method'] ?? '') === 'button') {
$field_array['glyph'] = 'ti ti-flame';
}
// The Rotate (log rotation) action renders as a rotate glyph button.
if ($field_name === 'rotate' && ($field_array['method'] ?? '') === 'button') {
$field_array['glyph'] = 'ti ti-rotate';
}
if (isset($field_array['class'])) {
$class = ' ' . $field_array['class'];
} else {
@@ -778,6 +794,11 @@ class CactiTableFilter {
} elseif ($method === 'timespan') {
$timespan_fields[$field_name] = $field_array;
} elseif ($this->field_is_bar($field_name, $field_array)) {
// Bar-routed Search needs a persistent label; some pages (clog) omit friendly_name, relying on an adjacent field's label.
if (($field_name === 'filter' || $field_name === 'rfilter') && empty($field_array['friendly_name'])) {
$field_array['friendly_name'] = __('Search');
}
$bar_fields[$field_name] = $field_array;
} elseif ($method === 'submit' || $method === 'button') {
if ($field_name !== 'go' && $field_name !== 'clear') {
@@ -790,11 +811,30 @@ class CactiTableFilter {
}
}
// Keep the bar selectors in a stable order: Rows first, Refresh second.
// Mirror the bar Search into the Edit dialog so a saved layout's search term is visible and
// editable there too; the bar input stays canonical and the mirror is synced to it in JS.
foreach (['rfilter', 'filter'] as $search_name) {
if (isset($bar_fields[$search_name])) {
$mirror = $bar_fields[$search_name];
if (empty($mirror['friendly_name'])) {
$mirror['friendly_name'] = __('Search');
}
// The value is synced from the live bar input when the dialog opens, avoiding a stale copy.
$mirror['value'] = '';
$dialog_fields[$search_name . '_dialog'] = $mirror;
break;
}
}
// Keep the bar selectors in a stable order: Search first (matching the legacy filters), then Rows, then Refresh.
if (cacti_sizeof($bar_fields)) {
$ordered = [];
foreach (['rows', 'refresh'] as $pref) {
foreach (['filter', 'rfilter', 'rows', 'refresh'] as $pref) {
if (isset($bar_fields[$pref])) {
$ordered[$pref] = $bar_fields[$pref];
unset($bar_fields[$pref]);
@@ -828,6 +868,9 @@ class CactiTableFilter {
// Refresh (reload the current view, keeping the selected layout) as a glyph button, right of the Edit group.
print "<div class='filterColumnButton'><button type='button' class='ui-button ui-corner-all ui-widget' id='layout_refresh' title='" . __esc('Refresh page') . "'><i class='ti ti-refresh'></i></button></div>" . PHP_EOL;
// Clear (reset the filter to its defaults) as an eraser glyph button, right of Refresh.
print "<div class='filterColumnButton'><button type='button' class='ui-button ui-corner-all ui-widget' id='layout_clear' title='" . __esc('Clear') . "'><i class='ti ti-eraser'></i></button></div>" . PHP_EOL;
// Page actions (Import, Export, Purge, Save, sort asc/desc, ...) belong on
// the bar beside the Layouts selector, not inside the Edit dialog.
if (cacti_sizeof($bar_actions)) {
@@ -1494,6 +1537,8 @@ class CactiTableFilter {
$js .= 'function layoutOpenDialog(forceNew) {' . PHP_EOL;
$js .= "\tlayoutDialogForceNew = forceNew;" . PHP_EOL;
$js .= "\t$('#layout_name').val(forceNew ? '' : (layoutSelectedOption().attr('data-name') || ''));" . PHP_EOL;
// Reflect the live bar Search value into the dialog mirror each time the dialog opens.
$js .= "\t$('#rfilter_dialog, #filter_dialog').each(function() { $(this).val($('#' + this.id.replace('_dialog', '')).val()); });" . PHP_EOL;
$js .= "\tvar buttons = [" . PHP_EOL;
// when a layout is selected (incl. one just saved) apply its own url so filter_layout carries through, otherwise run the plain filter apply
$js .= "\t\t{ text: " . json_encode($applyLabel) . ", click: function() { if (!layoutDirty) { $(this).dialog('close'); var lu = layoutSelectedOption().attr('data-url'); if (layoutSelectedId() != 0 && lu != undefined && lu != '') { loadUrl({ url: correctUrlParameters(lu) }); } else { " . $changeFunction . '; } } } },' . PHP_EOL;
@@ -1536,12 +1581,17 @@ class CactiTableFilter {
// correctUrlParameters rewrites action=tree to action=tree_content so the tree view regenerates graphs in #main instead of reloading the tree shell (no-op on other pages).
$js .= "\t$('#filter_layout').change(function() { layoutUpdateButtons(); var u = layoutSelectedOption().attr('data-url'); if (u != undefined && u != '') { loadUrl({ url: correctUrlParameters(u) }); } });" . PHP_EOL;
$js .= "\t$('#" . $this->form_id . "_dialog').on('change keyup', 'input, select, textarea', function() { layoutSetDirty(true); });" . PHP_EOL;
// Editing the dialog Search mirror updates the canonical bar input it shadows.
$js .= "\t$('#rfilter_dialog, #filter_dialog').on('input', function() { $('#' + this.id.replace('_dialog', '')).val(this.value); });" . PHP_EOL;
$js .= "\t$('#layout_edit').click(function() { layoutOpenDialog(false); });" . PHP_EOL;
$js .= "\t$('#layout_saveas').click(function() { layoutOpenDialog(true); });" . PHP_EOL;
$js .= "\t$('#layout_refresh').click(function() { $(this).find('i').addClass('icon-rotate'); var u = layoutSelectedOption().attr('data-url'); if (layoutSelectedId() != 0 && u != undefined && u != '') { loadUrl({ url: correctUrlParameters(u) }); } else { " . $changeFunction . '; } });' . PHP_EOL;
$js .= "\t$('#layout_clear').click(function() { " . $clearFunction . '; });' . PHP_EOL;
$js .= "\t$('#layout_rename').click(function() { var id = layoutSelectedId(); if (id == 0 || !layoutEditable()) { return; } layoutRenameDialog(id, layoutSelectedOption().attr('data-name') || ''); });" . PHP_EOL;
$js .= "\t$('#layout_delete').click(function() { var id = layoutSelectedId(); if (id == 0 || !layoutEditable()) { return; } layoutDeleteDialog(id); });" . PHP_EOL;
$js .= "\t$('#layout_name').keydown(function(e) { if (e.keyCode == 13) { e.preventDefault(); layoutDialogSave(layoutDialogForceNew); } });" . PHP_EOL;
// Enter in the bar Search box applies the filter; delegated so it survives ajax content reloads.
$js .= "\t$(document).off('keydown.cactiSearch', '#filter, #rfilter').on('keydown.cactiSearch', '#filter, #rfilter', function(e) { if (e.keyCode == 13) { e.preventDefault(); " . $changeFunction . '; } });' . PHP_EOL;
$js .= '});' . PHP_EOL;
$js .= '</script>' . PHP_EOL;
+3
View File
@@ -492,6 +492,9 @@ function sites() : void {
// create the page filter
$pageFilter = new CactiTableFilter(__('Sites'), 'sites.php', 'form_site', 'sess_site', 'sites.php?action=edit');
// The Sites list gains nothing from saved layouts; keep the classic legacy filter.
$pageFilter->render_layouts = false;
$pageFilter->rows_label = __('Sites');
$pageFilter->render();
+18 -3
View File
@@ -151,12 +151,17 @@ test('the modern filter keeps time controls on the bar and fields in the dialog'
$src = file_get_contents($root . '/lib/html_filter.php');
expect($src)->toContain('private function field_is_bar(string $field_name, array $field_array) : bool {');
expect($src)->toContain("=== 'timespan' || \$field_name === 'refresh'");
expect($src)->toContain("\$field_name === 'refresh'");
expect($src)->toContain("\$field_name === 'rows'");
// Search is routed onto the bar (right of Rows) and applies on Enter.
expect($src)->toContain("\$field_name === 'filter' || \$field_name === 'rfilter'");
expect($src)->toContain('keydown.cactiSearch');
// Bar-routed Search carries a persistent label even when a page omits friendly_name.
expect($src)->toContain("\$field_array['friendly_name'] = __('Search');");
// The row-count selector is always labeled Rows.
expect($src)->toContain("\$field_array['friendly_name'] = __('Rows');");
// Bar selectors are ordered Rows then Refresh and separated from the buttons.
expect($src)->toContain("foreach (['rows', 'refresh'] as \$pref) {");
// Bar selectors are ordered Search, Rows, then Refresh and separated from the buttons.
expect($src)->toContain("foreach (['filter', 'rfilter', 'rows', 'refresh'] as \$pref) {");
expect($src)->toContain('$bar_fields = $ordered + $bar_fields;');
expect($src)->toContain('// Separate the always-present Layouts selector (plus any Rows/Refresh) from the layout buttons.');
// Two separators: selectors|buttons and buttons|page-actions.
@@ -207,6 +212,16 @@ test('the modern javascript wires the edit dialog save and publish actions', fun
expect($src)->toContain('function layoutDialogSave(forceNew) {');
expect($src)->toContain('function layoutUpdateButtons() {');
expect($src)->toContain("layoutPost('layout_publish'");
// The bar Clear button (right of Refresh) resets the filter via the page clear action.
expect($src)->toContain("id='layout_clear'");
expect($src)->toContain("\$('#layout_clear').click(function() { \" . \$clearFunction");
// Search is mirrored into the Edit dialog and kept in sync with the canonical bar input.
expect($src)->toContain("\$dialog_fields[\$search_name . '_dialog'] = \$mirror;");
expect($src)->toContain('#rfilter_dialog, #filter_dialog');
// Page-action Purge renders as a flame glyph button.
expect($src)->toContain("\$field_array['glyph'] = 'ti ti-flame';");
// Page-action Rotate (log rotation) renders as a rotate glyph button.
expect($src)->toContain("\$field_array['glyph'] = 'ti ti-rotate';");
});
test('the edit dialog is tagged for theming and the modern theme skins it', function () use ($root) {
+1 -1
View File
@@ -123,7 +123,7 @@ test('the tablesorter transform replaces every removed jQuery API with native eq
// every removed jQuery 4 utility is rewritten to its native equivalent
assert.match(output, /typeof fn === 'function'/);
assert.match(output, /\( raw \)\.trim\(\)/);
assert.match(output, /String\(\( raw \) \?\? ''\)\.trim\(\)/);
assert.match(output, /typeof val === 'string'/);
assert.match(output, /\$\.isPlainObject\(obj\)/);
assert.match(output, /scope != null && scope === scope\.window/);