luci-base: enable table filtering via .filterrow

filter() is already available to pre-filter any data we
want to display in a table prior to render, although this
is primarily to filter on data types in a config file.

Now it is possible to display a header row (thead)
with text fields to search a table column. Set the table
property .filterrow to true to enable. It is null by default
so the filter header row is disabled. The filters work
cumulatively, so they can be used in combination.

Stabilize column widths so when nothing shows due to
filters, the filter header rows don't eat up all the table
width and jump around if action buttons are present at the
right end of data rows.

Signed-off-by: Paul Donald <newtwen+github@gmail.com>
This commit is contained in:
Paul Donald
2026-02-02 22:49:04 +01:00
parent 2da63d766a
commit 315dbfc749

View File

@@ -2545,6 +2545,28 @@ const CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection
* @default null
*/
/**
* Optional table filtering for table sections.
*
* Set `filterrow` to `true` to display a filter header row in the generated
* table with per-column text fields to search for string matches in the
* column. The filter row appears after the titles row.
*
* The filters work cumulatively: text in each field shall match
* an entry for the row to be displayed. The results are filtered live.
* Matching is case-sensitive, and partial, i.e. part or all of the result
* includes the search string.
*
* The filter fields assume the placeholder text `Filter ` suffixed with
* the column name, to ease correlation of filter fields to their corresponding
* column entries on narrow displays which might fold the columns over
* multiple lines.
*
* @name LuCI.form.TableSection.prototype#filterrow
* @type boolean
* @default null
*/
/**
* Optional footer row for table sections.
*
@@ -2702,6 +2724,8 @@ const CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection
sectionEl.appendChild(tableEl);
setTimeout(() => { try { this.stabilizeActionColumnWidth(tableEl); } catch (e) {} }, 0);
sectionEl.appendChild(this.renderSectionAdd('cbi-tblsection-create'));
dom.bindClassInstance(sectionEl, this);
@@ -2716,6 +2740,7 @@ const CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection
const max_cols = this.max_cols ?? this.children.length;
const has_more = max_cols < this.children.length;
const anon_class = (!this.anonymous || this.sectiontitle) ? 'named' : 'anonymous';
const tableFilter = this.map.data.get('luci', 'main', 'tablefilters') || false;
const trEls = E([]);
for (let i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) {
@@ -2775,6 +2800,103 @@ const CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection
trEls.appendChild(trEl);
}
if (this.filterrow && tableFilter) {
const filterTr = E('tr', { 'class': `tr cbi-section-table-filter ${anon_class}` });
if (!this.anonymous || this.sectiontitle) {
filterTr.appendChild(E('th', { 'class': 'th cbi-section-table-cell' }, [
E('input', {
'type': 'text',
'class': 'cbi-input cbi-section-filter',
'placeholder': _('Filter'),
})
]));
}
for (let i = 0, opt; i < max_cols && (opt = this.children[i]) != null; i++) {
if (opt.modalonly) continue;
const f = /flag/i.test(opt.__name__);
const th = E('th', { 'class': 'th cbi-section-table-cell' }, [
E('input', {
'type': 'text',
'class': 'cbi-input cbi-section-filter',
'placeholder': f ? _('0/1') : _('Filter') + ' ' + opt.title,
'maxlength': f ? 1 : '',
'style': f ? 'width: 30px;' : '',
})
]);
if (opt.width != null) th.style.width = (typeof(opt.width) == 'number') ? `${opt.width}px` : opt.width;
filterTr.appendChild(th);
}
if (this.sortable || this.extedit || this.addremove || has_more || has_action || this.cloneable) {
filterTr.appendChild(E('th', { 'class': 'th cbi-section-table-cell cbi-section-actions' }, [
E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'type': 'button',
'title': _('Reset filters'),
'click': () => {
const inputs = filterTr.querySelectorAll('input.cbi-section-filter');
inputs.forEach(i => {
i.value = '';
i.dispatchEvent(new Event('input', { bubbles: true }));
});
const tbl = filterTr.closest('table');
try { this.stabilizeActionColumnWidth(tbl); } catch (e) { }
}
}, [ _('Reset') ])
]));
}
const attachFn = (input) => {
input.addEventListener('input', (ev) => {
const tbl = ev.target.closest('table');
if (!tbl) return;
const inputs = tbl.querySelectorAll('tr.cbi-section-table-filter input');
const col_filts = Array.from(inputs).map(i => i.value.trim());
const rows = tbl.querySelectorAll('tr.tr.cbi-section-table-row');
rows.forEach(row => {
const cells = Array.from(row.children)
.filter(c => c.classList && c.classList.contains('td'));
let hide = false;
for (let k = 0; k < col_filts.length; k++) {
if (!col_filts[k]) continue;
let txt;
const cell = cells[k];
const checked = cell?.querySelector('input[type="checkbox"]')?.checked;
const select = cell?.querySelector('select');
const checkbox = checked !== undefined;
if (checkbox)
txt = checked ? '1' : '0';
else if (select)
txt = Array.from(select.selectedOptions)
.map(opt => opt.textContent || opt.value.toLowerCase())
.join(' ');
else
txt = cell.textContent || '';
if (!txt.includes(col_filts[k])) { hide = true; break; }
}
row.style.display = hide ? 'none' : '';
});
try { this.stabilizeActionColumnWidth(tbl); } catch (e) { /* ignore */ }
});
};
filterTr.querySelectorAll('input').forEach(attachFn);
trEls.appendChild(filterTr);
}
if (has_descriptions && !this.nodescriptions) {
const trEl = E('tr', {
'class': `tr cbi-section-table-descr ${anon_class}`
@@ -2845,6 +2967,49 @@ const CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection
},
/**
* Ensure the actions column keeps a stable width even when rows are hidden
* (e.g., due to filtering). Measures the widest actions cell and applies
* a fixed width to header/filter/footer/action cells. Stores measured width
* in dataset so filtering won't collapse the column if all rows are hidden.
*/
stabilizeActionColumnWidth(tableEl) {
if (!tableEl || !tableEl.querySelector) return;
const actionDivs = Array.from(tableEl.querySelectorAll('td.cbi-section-actions > div'));
let max = 0;
actionDivs.forEach(div => {
if (div && div.offsetWidth) max = Math.max(max, div.offsetWidth);
});
const saved = parseInt(tableEl.dataset.actionColWidth || '0', 10) || 0;
if (max <= 0 && saved > 0) max = saved;
if (max <= 0) return; // nothing measurable
tableEl.dataset.actionColWidth = String(max);
const px = `${max}px`;
const setStyles = (el) => {
if (!el) return;
el.style.minWidth = px;
el.style.width = px;
};
setStyles(tableEl.querySelector('th.cbi-section-actions'));
setStyles(tableEl.querySelector('tr.cbi-section-table-filter th.cbi-section-actions'));
setStyles(tableEl.querySelector('tr.cbi-section-table-footer td.cbi-section-actions'));
actionDivs.forEach(div => setStyles(div.parentNode));
// attach a single resize handler per table to recalc on viewport changes
if (!tableEl.__actionColResizeAttached) {
tableEl.__actionColResizeAttached = true;
window.addEventListener('resize', () => {
delete tableEl.dataset.actionColWidth; // force re-measure
this.stabilizeActionColumnWidth(tableEl);
});
}
},
/** @private */
renderRowActions(section_id, more_label, trEl) {
const config_name = this.uciconfig ?? this.map.config;