Add search/filter bar to large ListChoice checkbox lists

Checkmk's native checkbox-list widget renders one row per item with no
grouping or pagination, making rules like "Deploy custom files with
agent" hard to work with when only a few items need to be checked
among hundreds. Adds a search box, a live selected-count, and a
"Selected only" toggle above any such list with 15+ items.
This commit is contained in:
Luigi D'Acunto 2026-07-30 13:07:12 +02:00
parent 45d6187150
commit 69e872569c
3 changed files with 133 additions and 1 deletions

View file

@ -1,7 +1,7 @@
// ==UserScript==
// @name Checkmk SwissKnife
// @namespace https://luigidacunto.com/
// @version 2.16.2
// @version 2.17.0
// @checkmk 2.3.x - 2.4.x
// @description Collection of UI improvements for Checkmk WATO. Each fix or enhancement is added here as an independent feature.
// @author Luigi D'Acunto
@ -1275,6 +1275,7 @@
let attemptsChangelog = 0;
let attemptsAccToggle = 0;
let attemptsSitesFilter = 0;
let attemptsListchoice = 0;
function tryEnhanceFolderSelect() {
const iDoc = getWatoDoc(FOLDER_SELECT_ID);
@ -1449,6 +1450,117 @@
addAccordionToggleButtons(doc);
}
// =========================================================================
// FEATURE: ListChoice Filter Bar
//
// Checkmk's native ListChoice widget (table.listchoice) renders one row
// per item with no grouping or pagination. On rules like "Deploy custom
// files with agent" this means 100+ checkboxes to scroll through to find
// the 3-4 that actually need to be checked. Adds a search box, a live
// selected-count, and a "Selected only" toggle above each such table.
// Pure visibility filter (hides <tr> via CSS class): never touches
// checked state or name attributes, so native form submission on Save
// is completely unaffected.
// =========================================================================
const LISTCHOICE_MIN_ITEMS = 15; // below this, scrolling is not a real problem
function addListChoiceFilter(doc) {
if (doc.body.dataset.cmkListchoiceFilter === '1') return;
const tables = [...doc.querySelectorAll('table.listchoice')]
.filter(t => t.querySelectorAll('input[type="checkbox"]').length >= LISTCHOICE_MIN_ITEMS);
if (!tables.length) return;
doc.body.dataset.cmkListchoiceFilter = '1';
injectStyles(doc, 'cmk-sk-listchoice-filter-styles', `
.cmk-sk-lc-bar {
display: flex; align-items: center; gap: 8px;
padding: 5px 8px; margin-bottom: 4px;
background: rgba(0,0,0,0.25); border: 1px solid #3a3a3a; border-radius: 4px;
font-size: 11px; font-family: monospace; color: #999;
}
.cmk-sk-lc-search {
flex: 0 0 220px; padding: 3px 6px; font-size: 11px; font-family: monospace;
border-radius: 3px; border: 1px solid #555; background: #1c1c1c; color: #ddd;
}
.cmk-sk-lc-toggle {
cursor: pointer; padding: 3px 10px; border-radius: 3px; border: 1px solid #555;
background: #2a2a2a; color: #bbb; font-size: 11px; font-family: monospace;
font-weight: bold; letter-spacing: 0.03em;
}
.cmk-sk-lc-toggle:hover { background: #383838; }
.cmk-sk-lc-toggle.active { background: #1c3320; border-color: #4caf50; color: #4caf50; }
.cmk-sk-lc-hidden { display: none !important; }
`);
tables.forEach(table => {
const rows = [...table.querySelectorAll('tr')].map(tr => {
const cb = tr.querySelector('input[type="checkbox"]');
const label = tr.querySelector('label');
return cb && label ? { tr, cb, text: label.textContent.trim().toLowerCase() } : null;
}).filter(Boolean);
if (!rows.length) return;
const bar = doc.createElement('div');
bar.className = 'cmk-sk-lc-bar';
const search = doc.createElement('input');
search.type = 'text';
search.className = 'cmk-sk-lc-search';
search.placeholder = 'Filter…';
const toggle = doc.createElement('button');
toggle.type = 'button';
toggle.className = 'cmk-sk-lc-toggle';
toggle.textContent = 'Selected only';
const count = doc.createElement('span');
let selectedOnly = false;
function updateCount() {
const selected = rows.filter(r => r.cb.checked).length;
count.textContent = `${selected} / ${rows.length} selected`;
}
function applyFilter() {
const q = search.value.trim().toLowerCase();
rows.forEach(r => {
const matchesSearch = !q || r.text.includes(q);
const matchesToggle = !selectedOnly || r.cb.checked;
r.tr.classList.toggle('cmk-sk-lc-hidden', !(matchesSearch && matchesToggle));
});
}
search.addEventListener('input', applyFilter);
toggle.addEventListener('click', () => {
selectedOnly = !selectedOnly;
toggle.classList.toggle('active', selectedOnly);
toggle.textContent = selectedOnly ? 'Show all' : 'Selected only';
applyFilter();
});
rows.forEach(r => r.cb.addEventListener('change', updateCount));
bar.appendChild(search);
bar.appendChild(toggle);
bar.appendChild(count);
table.before(bar);
updateCount();
});
}
function tryAddListChoiceFilter() {
const doc = getTargetDoc();
if (!doc || !doc.body) {
if (++attemptsListchoice < MAX_ATTEMPTS) setTimeout(tryAddListChoiceFilter, POLL_INTERVAL_MS);
return;
}
addListChoiceFilter(doc);
}
function init() {
const iDoc = getWatoDoc(FOLDER_SELECT_ID);
const mode = getPageMode(iDoc);
@ -1463,6 +1575,7 @@
attemptsChangelog = 0;
attemptsAccToggle = 0;
attemptsSitesFilter = 0;
attemptsListchoice = 0;
// Folder select: self-stops if element not found, always schedules.
setTimeout(tryEnhanceFolderSelect, 800);
// Accordion: only on pages in ACCORDION_MODES.
@ -1485,6 +1598,8 @@
if (!targetMode || targetMode === 'changelog') setTimeout(tryAutoCheckForeignActivation, 500);
// Sites status filters: only on mode=sites, self-stops if not applicable.
if (!targetMode || targetMode === 'sites') setTimeout(tryAddSitesFilterBar, 500);
// ListChoice filter bar: on any page with a large table.listchoice, self-stops if not applicable.
setTimeout(tryAddListChoiceFilter, 600);
}
if (document.readyState === 'complete') {
@ -1541,6 +1656,10 @@
attemptsSitesFilter = 0;
setTimeout(tryAddSitesFilterBar, 300);
}
if (tDoc && tDoc.body && !tDoc.body.dataset.cmkListchoiceFilter) {
attemptsListchoice = 0;
setTimeout(tryAddListChoiceFilter, 300);
}
}).observe(document.body, { childList: true, subtree: true });
// Restart on iframe load (sidebar layout)