From 315dbfc7498e2f43afb0119b992915e8f311bc37 Mon Sep 17 00:00:00 2001 From: Paul Donald Date: Mon, 2 Feb 2026 22:49:04 +0100 Subject: [PATCH] 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 --- .../htdocs/luci-static/resources/form.js | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/modules/luci-base/htdocs/luci-static/resources/form.js b/modules/luci-base/htdocs/luci-static/resources/form.js index f5e67078d2..b2f308fc51 100644 --- a/modules/luci-base/htdocs/luci-static/resources/form.js +++ b/modules/luci-base/htdocs/luci-static/resources/form.js @@ -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;