From 973ff592a444d20273be4cd39314a863ed6bebbb Mon Sep 17 00:00:00 2001 From: Johan Sandoval Date: Tue, 6 Oct 2026 18:03:33 +0000 Subject: [PATCH] Filter menus: None first, documented picks; Custom View drag preview - Fix: sorting a software column stopped its filter listing apps individually (the sort arrow written into the header changed the label used to recognise list columns) - "None" (nothing missing) is pinned to the top of the value list, so the easy devices are one click away - Software columns get "Documented" / "Undocumented" quick picks that tick only apps with / without a knowledge-base article; documented apps are marked in the list - Custom View: remove Reset Filters / Reset Sort (Clear filters and the column menus replace them); old saved filters are cleared since nothing else could clear them - Custom View column drag: a ghost of the column follows the cursor, the column being moved is dimmed, and a line shows where it will land --- web/excel-filter.js | 50 +++++++++++++++++++++++++++++++-- web/index.html | 2 -- web/layout.css | 35 +++++++++++++++++++++++ web/layout.js | 67 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 5 deletions(-) diff --git a/web/excel-filter.js b/web/excel-filter.js index f918f1e..757f763 100644 --- a/web/excel-filter.js +++ b/web/excel-filter.js @@ -29,7 +29,7 @@ const nativeSort = {}; // table key -> { col, dir } for tables without their own sort (Workbench) const norm = s => String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); - const cleanLabel = t => norm(String(t).replace(/[↕▲▼▾×✕]/g, '')); + const cleanLabel = t => norm(String(t).replace(/[↑↓↕▲▼▾×✕]/g, '')); // sort arrows, remove x const FUNNEL_SVG = ''; const CARET_SVG = ''; @@ -334,7 +334,9 @@ if (!passes(ad, cols, tr, key)) continue; for (const v of cellValues(tr.cells[idx], col)) { if (v === BLANK) hasBlank = true; else seen.add(v); } } - const values = Array.from(seen).sort(collator.compare); + // "None" (nothing missing) first so the easy devices are one click away; (Blanks) last + const values = Array.from(seen).filter(v => v !== 'None').sort(collator.compare); + if (seen.has('None')) values.unshift('None'); if (hasBlank) values.push(BLANK); menuCtx = { table, idx }; @@ -382,8 +384,26 @@ list.appendChild(row); return { row, cb }; }; + // Software columns: quick picks for apps that do / don't have a knowledge-base article + const isSoftware = /software/i.test(col.label); + const docs = (typeof availableDocs !== 'undefined' && Array.isArray(availableDocs)) ? availableDocs.map(d => d.toLowerCase()) : []; + const isDocumented = v => docs.includes(String(v).toLowerCase()); + let pickBar = null; + if (isSoftware && values.some(v => v !== 'None' && v !== BLANK)) { + pickBar = el('div', 'xf-picks'); + menu.appendChild(pickBar); + } + const all = mkRow('(Select All)', true, 'xf-all'); - const opts = values.map(v => ({ v, ...mkRow(v, !current || current.has(v)) })); + const opts = values.map(v => { + const o = { v, ...mkRow(v, !current || current.has(v)) }; + if (isSoftware && isDocumented(v)) { + o.row.classList.add('xf-doc'); + o.row.title = 'Has a documentation article'; + } + if (v === 'None') o.row.classList.add('xf-none'); + return o; + }); if (!values.length) list.appendChild(el('div', 'xf-empty', 'No values')); menu.appendChild(list); @@ -403,6 +423,24 @@ }; all.cb.onchange = () => { visibleOpts().forEach(o => { o.cb.checked = all.cb.checked; }); sync(); }; opts.forEach(o => { o.cb.onchange = sync; }); + if (pickBar) { + const pick = (label, test) => { + const n = opts.filter(o => o.v !== 'None' && o.v !== BLANK && test(o.v)).length; + const b = el('button', 'xf-pick'); b.type = 'button'; + b.textContent = `${label} (${n})`; + b.title = `Tick only the apps that ${label === 'Documented' ? 'have' : "don't have"} a documentation article`; + b.disabled = n === 0; + b.onclick = () => { + search.value = ''; + opts.forEach(o => { o.row.hidden = false; o.cb.checked = o.v !== 'None' && o.v !== BLANK && test(o.v); }); + all.row.querySelector('span').textContent = '(Select All)'; + sync(); + }; + pickBar.appendChild(b); + }; + pick('Documented', isDocumented); + pick('Undocumented', v => !isDocumented(v)); + } search.oninput = () => { const q = search.value.trim().toLowerCase(); // Excel: searching shows the matching items, all ticked @@ -558,6 +596,12 @@ wrap('filterCustomTab', () => afterFilterPass(document.getElementById('table-custom'))); wrap('refreshStackTable', id => afterFilterPass(document.getElementById(`table-${id}`))); wrap('resetCustomFilters', () => { delete filters.custom; filterCustomTab(); }); + // Custom View's old filters (saved in the browser by earlier versions) have no UI any + // more, so they're cleared rather than left silently hiding rows + if (typeof customFilters !== 'undefined' && customFilters && Object.keys(customFilters).length) { + customFilters = {}; + if (typeof saveCustomState === 'function') saveCustomState(); + } // Custom View's own filter icon opens the Excel-style menu instead window.openColFilter = (colId, idx, anchor) => openMenu(document.getElementById('table-custom'), idx, anchor); diff --git a/web/index.html b/web/index.html index 3b9597c..28921e9 100755 --- a/web/index.html +++ b/web/index.html @@ -536,8 +536,6 @@ - -
diff --git a/web/layout.css b/web/layout.css index d0ae741..eeb8f4e 100644 --- a/web/layout.css +++ b/web/layout.css @@ -344,3 +344,38 @@ table.xf-table thead th:has(> .xf-btn):not(.sortable-th) { position: relative; p .xf-addfield[hidden] { display: none; } .xf-clear-all { white-space: nowrap; color: #f1c40f !important; border-color: rgba(241,196,15,0.6) !important; } .xf-clear-all svg { width: 14px; height: 14px; flex: none; } + +/* Software menus: documented / undocumented quick picks, doc marker, "None" pinned first */ +.xf-picks { display: flex; gap: 6px; padding: 0 12px 6px; } +.xf-pick { + flex: 1; padding: 5px 6px; cursor: pointer; font: inherit; font-size: 0.82em; white-space: nowrap; + background: transparent; color: #3498db; border: 1px solid rgba(52,152,219,0.55); border-radius: 5px; +} +.xf-pick:hover:not(:disabled) { background: rgba(52,152,219,0.12); } +.xf-pick:disabled { opacity: 0.4; cursor: default; } +.xf-opt.xf-doc span::after { + content: ''; display: inline-block; width: 9px; height: 11px; margin-left: 6px; vertical-align: -1px; + border: 1.5px solid #3498db; border-radius: 1.5px; box-sizing: border-box; + background: linear-gradient(#3498db, #3498db) 2px 3px / 3px 1.5px no-repeat, + linear-gradient(#3498db, #3498db) 2px 6px / 3px 1.5px no-repeat; +} +.xf-opt.xf-none { font-style: italic; border-bottom: 1px dashed var(--border-light); } + +/* Custom View column drag preview (layout.js) */ +.col-drag-ghost { + position: fixed; top: -1000px; left: -1000px; z-index: 9999; pointer-events: none; + background: var(--bg-card); color: var(--text-main); border: 2px solid #3498db; border-radius: 6px; + box-shadow: 0 10px 24px rgba(0,0,0,0.45); overflow: hidden; font-size: 12px; +} +.col-drag-ghost-head { padding: 7px 10px; font-weight: 700; color: #fff; background: var(--table-header-bg); } +.col-drag-ghost-cell { + padding: 5px 10px; border-top: 1px solid var(--border-light); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +#table-custom .col-moving { opacity: 0.35; } +.col-drop-marker { + position: absolute; top: 0; width: 4px; z-index: 30; pointer-events: none; + background: #3498db; border-radius: 2px; box-shadow: 0 0 8px rgba(52,152,219,0.8); +} +/* the single insertion line replaces the per-header highlight */ +#table-custom .custom-th.drag-over { box-shadow: none !important; } diff --git a/web/layout.js b/web/layout.js index 6adbe2f..945e812 100644 --- a/web/layout.js +++ b/web/layout.js @@ -142,6 +142,67 @@ }); } + // Custom View column drag: a ghost of the column follows the cursor, the column being + // moved is dimmed, and a line shows exactly where it will land. Adds to the dashboard's + // own drag-and-drop (attachColDragHandlers), which still performs the move. + function enhanceColumnDrag() { + const table = document.getElementById('table-custom'); + const host = document.getElementById('custom-table-host'); + if (!table || !host) return; + const headers = Array.from(table.querySelectorAll('thead th.custom-th')); + let from = null, marker = null; + + const setDimmed = (idx, on) => { + for (const row of table.rows) { const c = row.cells[idx]; if (c) c.classList.toggle('col-moving', on); } + }; + const clear = () => { + if (from !== null) setDimmed(from, false); + if (marker) marker.remove(); + marker = null; from = null; + }; + + headers.forEach((th, idx) => { + const grip = th.querySelector('.col-grip'); + if (!grip || grip.dataset.ghost) return; + grip.dataset.ghost = '1'; + + grip.addEventListener('dragstart', e => { + from = idx; + const ghost = document.createElement('div'); + ghost.className = 'col-drag-ghost'; + const head = document.createElement('div'); + head.className = 'col-drag-ghost-head'; + head.textContent = th.querySelector('.label-text') ? th.querySelector('.label-text').textContent : th.textContent.trim(); + ghost.appendChild(head); + Array.from(table.tBodies[0].rows).filter(r => r.style.display !== 'none').slice(0, 7).forEach(r => { + const cell = document.createElement('div'); + cell.className = 'col-drag-ghost-cell'; + cell.textContent = r.cells[idx] ? r.cells[idx].textContent.trim() : ''; + ghost.appendChild(cell); + }); + ghost.style.width = Math.min(280, Math.max(140, th.getBoundingClientRect().width)) + 'px'; + document.body.appendChild(ghost); + try { e.dataTransfer.setDragImage(ghost, 24, 18); } catch (_) {} + setTimeout(() => ghost.remove(), 0); + setDimmed(idx, true); + }); + grip.addEventListener('dragend', clear); + + th.addEventListener('dragover', () => { + if (from === null) return; + if (!marker) { marker = document.createElement('div'); marker.className = 'col-drop-marker'; host.appendChild(marker); } + // moveCustomColumn(from, to) lands the column after the target when moving right, + // before it when moving left + const hostBox = host.getBoundingClientRect(), box = th.getBoundingClientRect(); + const x = (idx > from ? box.right : box.left) - hostBox.left; + marker.style.left = (x - 2) + 'px'; + marker.style.height = table.getBoundingClientRect().height + 'px'; + marker.hidden = idx === from; + }); + th.addEventListener('drop', clear); + }); + } + let queued = false; function run() { queued = false; @@ -164,6 +225,12 @@ // Groups and pages are shown by toggling display (not a DOM change), so also // re-measure as the user scrolls; writes only happen when a value changes. window.addEventListener('scroll', schedule, { passive: true }); + // Custom View rebuilds its header (and re-attaches drag handlers) on every render + if (typeof window.attachColDragHandlers === 'function') { + const origDrag = window.attachColDragHandlers; + window.attachColDragHandlers = function () { const r = origDrag.apply(this, arguments); enhanceColumnDrag(); return r; }; + enhanceColumnDrag(); + } // Page switches show and hide pages without changing the DOM if (typeof window.switchPage === 'function') { const orig = window.switchPage;