From 668a135e539581f7374dc858d59e44d4ebfb7b22 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Tue, 12 May 2026 10:10:18 -0400 Subject: [PATCH] feat: replace multi-select fields with dual-panel picker on settings page Replaces the 5 native {%- for item in all_items %}{%- endfor %} + {%- endmacro %} {% include 'nav.html' %}
@@ -501,11 +557,7 @@ - + {{ mpick(all_channel_names, u.notification_channels, 'user-ch-select') }} @@ -576,34 +628,10 @@ - - - - - - - - - - - - + {{ mpick(all_usernames, h.managers, 'host-managers') }} + {{ mpick(all_usernames, h.monitors, 'host-monitors') }} + {{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }} + {{ mpick(all_channel_names, h.notification_channels, 'host-channels') }} {% endfor %} @@ -1008,10 +1036,6 @@ function addHostRow() { const tbody = document.getElementById('hosts-tbody'); - const sz = n => Math.min(Math.max(n, 1), 4); - const usersOpts = _allUsers.map(u => ``).join(''); - const tcOpts = _allThresholdConfigs.map(t => ``).join(''); - const chOpts = _allChannels.map(c => ``).join(''); const row = document.createElement('tr'); row.setAttribute('data-new-host', 'true'); row.innerHTML = ` @@ -1019,10 +1043,10 @@ - - - - + ${makeMpickHTML(_allUsers, [], 'host-managers')} + ${makeMpickHTML(_allUsers, [], 'host-monitors')} + ${makeMpickHTML(_allThresholdConfigs, [], 'host-tc')} + ${makeMpickHTML(_allChannels, [], 'host-channels')} `; tbody.appendChild(row); } @@ -1097,8 +1121,6 @@ function addUserRow() { const tbody = document.getElementById('users-tbody'); - const opts = _allChannels.map(ch => ``).join(''); - const chHtml = ``; const row = document.createElement('tr'); row.setAttribute('data-new-user', 'true'); row.innerHTML = ` @@ -1106,7 +1128,7 @@ - ${chHtml} + ${makeMpickHTML(_allChannels, [], 'user-ch-select')} `; tbody.appendChild(row); @@ -1186,6 +1208,121 @@ return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } + // ---- Multi-picker ---- + let _mpickPanel = null; + let _mpickTarget = null; + + function _initMpickPanel() { + if (_mpickPanel) return; + const p = document.createElement('div'); + p.className = 'mpick-panel'; + p.style.display = 'none'; + p.innerHTML = ` +
+ Select items + +
+
+
+
Available
+
+
+
+
Selected
+
+
+
+ `; + document.body.appendChild(p); + _mpickPanel = p; + document.addEventListener('mousedown', e => { + if (!_mpickPanel || _mpickPanel.style.display === 'none') return; + if (!_mpickPanel.contains(e.target) && !e.target.closest('.mpick-display')) closeMpick(); + }, true); + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && _mpickPanel && _mpickPanel.style.display !== 'none') closeMpick(); + }); + } + + function openMpick(displayEl) { + if (displayEl.closest('tr')?.dataset.deleted === 'true') return; + _initMpickPanel(); + _mpickTarget = displayEl.closest('.mpick-wrapper'); + _rerenderMpick(); + _mpickPanel.style.display = 'block'; + const rect = displayEl.getBoundingClientRect(); + const pw = _mpickPanel.offsetWidth || 360; + const ph = _mpickPanel.offsetHeight || 280; + let top = rect.bottom + 4; + let left = rect.left; + if (left + pw > window.innerWidth - 8) left = Math.max(8, window.innerWidth - pw - 8); + if (top + ph > window.innerHeight - 8) top = Math.max(8, rect.top - ph - 4); + _mpickPanel.style.top = top + 'px'; + _mpickPanel.style.left = left + 'px'; + } + + function _rerenderMpick() { + const sel = _mpickTarget.querySelector('select'); + const allOpts = [...sel.options]; + const selVals = new Set([...sel.selectedOptions].map(o => o.value)); + const avail = allOpts.filter(o => !selVals.has(o.value)); + const chosen = allOpts.filter(o => selVals.has(o.value)); + document.getElementById('mpick-avail').innerHTML = avail.length + ? avail.map(o => `
${escHtml(o.value)}+
`).join('') + : '
All selected
'; + document.getElementById('mpick-sel').innerHTML = chosen.length + ? chosen.map(o => `
${escHtml(o.value)}
`).join('') + : '
None selected
'; + } + + function _mpickToggle(itemEl, toSelected) { + const val = itemEl.dataset.val; + const sel = _mpickTarget.querySelector('select'); + const opt = [...sel.options].find(o => o.value === val); + if (opt) opt.selected = toSelected; + _updateMpickDisplay(_mpickTarget); + _rerenderMpick(); + } + + function _updateMpickDisplay(wrapper) { + const sel = wrapper.querySelector('select'); + const display = wrapper.querySelector('.mpick-display'); + const selected = [...sel.selectedOptions].map(o => o.value); + if (!selected.length) { + display.innerHTML = '(none)'; + display.title = ''; + return; + } + const MAX = 2; + let html = selected.slice(0, MAX).map(v => `${escHtml(v)}`).join(''); + if (selected.length > MAX) html += `+${selected.length - MAX}`; + display.innerHTML = html; + display.title = selected.join(', '); + } + + function closeMpick() { + if (_mpickPanel) _mpickPanel.style.display = 'none'; + _mpickTarget = null; + } + + function makeMpickHTML(allItems, selectedItems, cls) { + const selSet = new Set(selectedItems); + const opts = allItems.map(v => ``).join(''); + const selected = allItems.filter(v => selSet.has(v)); + const MAX = 2; + let dispHtml; + if (!selected.length) { + dispHtml = '(none)'; + } else { + dispHtml = selected.slice(0, MAX).map(v => `${escHtml(v)}`).join(''); + if (selected.length > MAX) dispHtml += `+${selected.length - MAX}`; + } + const title = escHtml(selected.join(', ')); + return `
${dispHtml}
`; + } + // Highlight sidebar link for the section currently in view const sections = document.querySelectorAll('.section'); const navLinks = document.querySelectorAll('.sidebar-nav a');