/* * Excel-style column filters for every dashboard table. * * Modeled on Excel's AutoFilter: each column header gets a dropdown with * Sort A to Z / Sort Z to A, Clear Filter From "", a Search box, and a checkbox * list of every value in the column with (Select All) and (Blanks), then OK / Cancel. * The value list only shows values from rows that pass the OTHER columns' filters, filters * on several columns combine (AND), and a filtered column's button shows a funnel. * List-type columns (Tags, Missing/Specialty Software) are split so each tag or app is * its own checkbox; a row matches if it has ANY of the ticked items, or with "Exactly * ticked" if its items are exactly the ticked set (no more, no fewer). Free-space columns ("78 GB / 221 GB") filter * by disk size plus a free-space threshold, and sort by disk size or by free space. * * It plugs into the dashboard's existing filter passes (applyFilters, filterSoftwareTab, * filterCustomTab, refreshStackTable): those run first (global search etc.), then rows * failing a column filter are hidden and the counts are updated. Menus are appended to * with a fixed position, so sticky headers and neighbouring tables can't cover them. * On phones and tablets (card view) a sticky bar of column chips opens the same menus. */ (function () { 'use strict'; const cardsQuery = window.matchMedia('(max-width: 1100px)'); const sheetQuery = window.matchMedia('(max-width: 600px)'); const LIST_COLUMNS = /^(tags|missing software|specialty software|source devices)$/i; const SKIP_COLUMNS = /(^|\s)apps$/i; // "Apps" button columns have nothing to filter const BLANK = '(Blanks)'; const NO_READING = '(No reading)'; // space column: offline / cached / not checked const SPACE_RE = /([\d.]+)\s*GB\s*\/\s*([\d.]+)\s*GB/i; const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); const filters = {}; // table key -> { column key -> Set of allowed values } 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, '')); // sort arrows, remove x const FUNNEL_SVG = ''; const CARET_SVG = ''; const ARROW_UP_SVG = ''; const ARROW_DOWN_SVG = ''; const SORT_ASC_SVG = ''; const SORT_DESC_SVG = ''; const CLEAR_SVG = ''; // ------------------------------------------------------------------------------------ // Which table is this, and how does the dashboard filter / sort / refresh it? // ------------------------------------------------------------------------------------ function adapter(table) { if (!table || !table.id) return null; const id = table.id; if (id === 'table-custom') { return { key: 'custom', custom: true, colKey: i => (typeof customColumns !== 'undefined' ? customColumns[i] : i), rerun: () => filterCustomTab(), sortable: () => true, sort: (i, dir) => { sortStates['custom'] = { col: i, dir }; renderCustomTable(); }, sortDir: i => { const s = sortStates['custom']; return s && s.col === i ? s.dir : 'none'; } }; } if (id === 'table-software') { return { key: 'software', colKey: i => i, rerun: () => filterSoftwareTab(), sortable: th => th.classList.contains('sortable-th') && th.hasAttribute('onclick'), sort: (i, dir) => { sortStates['software'] = { col: i, dir }; renderSoftwareTable(); }, sortDir: i => { const s = sortStates['software']; return s && s.col === i ? s.dir : 'none'; } }; } if (id === 'ov-table' && window.Overview) { const key = Overview.listKey(); return { key, native: true, colKey: i => i, rerun: () => Overview.renderList(), sortable: () => true, sort: (i, dir) => { nativeSort[key] = { col: i, dir }; Overview.renderList(); }, sortDir: i => { const s = nativeSort[key]; return s && s.col === i ? s.dir : 'none'; } }; } if (table.closest('#stackGrid')) { const sid = id.replace(/^table-/, ''); const key = 'stack-' + sid; const stackId = () => { const s = (typeof myStacks !== 'undefined' ? myStacks : []).find(x => String(x.id) === sid); return s ? s.id : sid; }; return { key, native: true, colKey: i => i, rerun: () => refreshStackTable(stackId()), sortable: () => true, sort: (i, dir) => { nativeSort[key] = { col: i, dir }; refreshStackTable(stackId()); }, sortDir: i => { const s = nativeSort[key]; return s && s.col === i ? s.dir : 'none'; } }; } if (table.closest('#masterList')) { const g = id.replace(/^table-/, ''); return { key: 'group-' + g, group: g, colKey: i => i, rerun: () => applyFilters(g), sortable: th => th.classList.contains('sortable-th'), sort: (i, dir) => { sortStates[g] = { col: i, dir }; renderMasterList(); }, sortDir: i => { const s = sortStates[g]; return s && s.col === i ? s.dir : 'none'; } }; } return null; } function headerRow(table) { if (!table.tHead) return null; let best = null, score = -1; for (const row of table.tHead.rows) { if (row.classList.contains('filter-row')) continue; const n = Array.from(row.cells).filter(c => cleanLabel(c.textContent)).length; if (n > score) { score = n; best = row; } } return best; } function columnsOf(table, ad) { const row = headerRow(table); if (!row) return []; return Array.from(row.cells).map((th, idx) => { const label = cleanLabel(th.textContent); return { idx, th, label, list: LIST_COLUMNS.test(label), space: /space/i.test(label), filterable: !!label && !SKIP_COLUMNS.test(label), sortable: !!label && !SKIP_COLUMNS.test(label) && !!ad.sortable(th) }; }); } const dataRows = table => Array.from(table.tBodies[0] ? table.tBodies[0].rows : []) .filter(r => !(r.cells.length === 1 && r.cells[0].colSpan > 1)); const rawText = td => norm(td ? (td.getAttribute('data-val') != null ? td.getAttribute('data-val') : td.textContent) : ''); function parseSpace(td) { const m = rawText(td).match(SPACE_RE); return m ? { free: parseFloat(m[1]), total: parseFloat(m[2]) } : null; } function cellValues(td, col) { const raw = rawText(td); if (col.space) { // list a space cell under its disk size const sp = parseSpace(td); return [sp ? `${sp.total} GB` : NO_READING]; } if (!raw) return [BLANK]; if (col.list) { const parts = raw.split(',').map(norm).filter(Boolean); return parts.length ? parts : [BLANK]; } return [raw]; } function activeFilters(ad, cols) { const set = filters[ad.key]; if (!set) return []; return cols.filter(c => c.filterable && set[ad.colKey(c.idx)]); } function passes(ad, cols, tr, exceptKey) { const set = filters[ad.key]; if (!set) return true; for (const col of cols) { if (!col.filterable) continue; const k = ad.colKey(col.idx); if (k === exceptKey) continue; const allowed = set[k]; if (!allowed) continue; const td = tr.cells[col.idx]; if (col.space && allowed.free) { const sp = parseSpace(td); if (!sp) return false; if (allowed.free.op === 'ge' ? sp.free < allowed.free.gb : sp.free >= allowed.free.gb) return false; } const vals = cellValues(td, col); if (col.list && allowed.only === 'subset') { // only ticked: nothing outside the ticked items (some of them is fine) if (!vals.every(v => allowed.has(v))) return false; } else if (col.list && allowed.only) { // exact set: the row's items are precisely the ticked ones, no more, no fewer const own = new Set(vals); if (own.size !== allowed.size || !vals.every(v => allowed.has(v))) return false; } else if (!vals.some(v => allowed.has(v))) return false; } return true; } // ------------------------------------------------------------------------------------ // After the dashboard's own filter pass: hide rows failing a column filter, fix counts // ------------------------------------------------------------------------------------ function afterFilterPass(table) { const ad = adapter(table); if (!ad) return; table.classList.add('xf-table'); const cols = columnsOf(table, ad); if (nativeSort[ad.key] && nativeSort[ad.key].dir !== 'none') nativeSortRows(table, cols, nativeSort[ad.key]); let visible = 0; for (const tr of dataRows(table)) { if (tr.style.display === 'none') continue; if (!passes(ad, cols, tr, undefined)) { tr.style.display = 'none'; continue; } visible++; } if (ad.group) { const header = document.getElementById(`group-header-${ad.group}`); if (header) header.innerText = `${ad.group.replace(/-/g, ' ')} (${visible})`; } if (ad.custom) { const cnt = document.getElementById('customCount'); if (cnt) cnt.innerText = `(${visible})`; } if (typeof updateTotalCounts === 'function') updateTotalCounts(); decorate(table, ad, cols); } function isDocumentedApp(v) { const docs = (typeof availableDocs !== 'undefined' && Array.isArray(availableDocs)) ? availableDocs : []; const l = String(v).toLowerCase(); return docs.some(d => d.toLowerCase() === l); } function nativeSortRows(table, cols, s) { const col = cols[s.col]; if (!col) return; const body = table.tBodies[0]; const rows = dataRows(table); const num = tr => { const sp = parseSpace(tr.cells[s.col]); return sp ? sp[s.by] : null; }; if (s.by === 'docs') { // undocumented apps, then all apps: a device with only documented apps sorts first const key = tr => { const vals = cellValues(tr.cells[s.col], col).filter(v => v !== 'None' && v !== BLANK); return [vals.filter(v => !isDocumentedApp(v)).length, vals.length]; }; const keyed = rows.map(r => [r, key(r)]); keyed.sort((a, b) => (a[1][0] - b[1][0]) * (s.dir === 'desc' ? -1 : 1) || a[1][1] - b[1][1]); keyed.forEach(([r]) => body.appendChild(r)); return; } rows.sort((a, b) => { if (s.by) { // disk size / free space: no reading always last const x = num(a), y = num(b); if (x === null || y === null) return (x === null) - (y === null); return s.dir === 'desc' ? y - x : x - y; } const r = collator.compare(norm(a.cells[s.col] ? a.cells[s.col].textContent : ''), norm(b.cells[s.col] ? b.cells[s.col].textContent : '')); return s.dir === 'desc' ? -r : r; }); rows.forEach(r => body.appendChild(r)); } // Header dropdown buttons, the "Clear filters" button, and the phone/tablet chip bar function decorate(table, ad, cols) { cols = cols || columnsOf(table, ad); const active = activeFilters(ad, cols); const activeKeys = new Set(active.map(c => ad.colKey(c.idx))); // Header buttons (Custom View's goes in its icon group, replacing its filter + sort icons) { cols.forEach(c => { if (!c.filterable && !c.sortable) return; const holder = ad.custom ? (c.th.querySelector('.th-icons') || c.th) : c.th; let btn = holder.querySelector(':scope > .xf-btn'); if (!btn) { btn = document.createElement('button'); btn.type = 'button'; btn.className = 'xf-btn no-copy'; btn.title = 'Sort and filter'; btn.addEventListener('click', e => { e.stopPropagation(); openMenu(table, c.idx, btn); }); holder.appendChild(btn); } // Like Excel's button: funnel when filtered, an arrow when sorted, otherwise a caret const on = activeKeys.has(ad.colKey(c.idx)); const dir = sortDirOf(ad, c.idx); const state = on ? 'filtered' : (dir === 'asc' || dir === 'desc') ? dir : 'plain'; btn.classList.toggle('xf-on', on); btn.classList.toggle('xf-sorted', state === 'asc' || state === 'desc'); if (btn.dataset.state !== state) { btn.innerHTML = state === 'filtered' ? FUNNEL_SVG : state === 'asc' ? ARROW_UP_SVG : state === 'desc' ? ARROW_DOWN_SVG : CARET_SVG; btn.dataset.state = state; } }); } // "Clear filters" next to the group's copy buttons const gh = groupHeaderFor(table); const btnBox = gh && Array.from(gh.children).find(el => el.tagName === 'DIV'); if (btnBox) { let clr = btnBox.querySelector('.xf-clear-all'); if (active.length && !clr) { clr = document.createElement('button'); clr.type = 'button'; clr.className = 'copy-table-btn no-copy xf-clear-all'; clr.addEventListener('click', () => { delete filters[ad.key]; ad.rerun(); }); btnBox.insertBefore(clr, btnBox.firstChild); } if (clr) { if (!active.length) clr.remove(); else if (clr.dataset.n !== String(active.length)) { // only touch the DOM on change clr.innerHTML = `${CLEAR_SVG} Clear filters (${active.length})`; clr.dataset.n = String(active.length); } } } // Card view: sticky bar of column chips let bar = table.previousElementSibling; if (!bar || !bar.classList.contains('xf-bar')) { if (!cardsQuery.matches) return; bar = document.createElement('div'); bar.className = 'xf-bar'; table.parentNode.insertBefore(bar, table); } const parts = []; if (ad.custom) parts.push(''); cols.forEach(c => { if (!c.filterable && !c.sortable) return; const on = activeKeys.has(ad.colKey(c.idx)); const n = on ? filters[ad.key][ad.colKey(c.idx)].size : 0; const dir = sortDirOf(ad, c.idx); const arrow = dir === 'asc' ? ' ↑' : dir === 'desc' ? ' ↓' : ''; parts.push(``); }); if (active.length) parts.push(''); const sig = JSON.stringify([parts, cols.map(c => c.label)]); if (bar.dataset.sig !== sig) { bar.innerHTML = parts.join(''); // labels via textContent (column names can come from user-chosen fields) bar.querySelectorAll('.xf-chip[data-idx]').forEach(chip => { chip.querySelector('span').textContent = cols[+chip.dataset.idx].label; }); bar.dataset.sig = sig; bar.onclick = e => { const chip = e.target.closest('.xf-chip'); if (!chip) return; if (chip.dataset.act === 'clear') { delete filters[ad.key]; ad.rerun(); return; } if (chip.dataset.act === 'fields') { openFieldEditor(); return; } openMenu(table, +chip.dataset.idx, chip); }; } } // Effective sort direction of a column: a size/free-space sort overrides the table's own function sortDirOf(ad, i) { const ns = nativeSort[ad.key]; if (ns && ns.dir !== 'none' && (ns.by || ad.native)) return ns.col === i ? ns.dir : 'none'; return ad.sortDir(i); } function applySort(ad, i, dir, by) { if (by) { // numeric sort we do ourselves, after each render nativeSort[ad.key] = { col: i, dir, by }; if (ad.native) ad.rerun(); else ad.sort(-1, 'none'); // re-render without the table's own sort return; } if (!ad.native) delete nativeSort[ad.key]; ad.sort(i, dir); } function groupHeaderFor(table) { for (let el = table.parentElement, depth = 0; el && depth < 3; el = el.parentElement, depth++) { const gh = el.querySelector(':scope > .group-header'); if (gh) return gh; } return null; } // ------------------------------------------------------------------------------------ // The dropdown menu // ------------------------------------------------------------------------------------ let menu = null, backdrop = null, menuCtx = null; function closeMenu() { if (menu) menu.remove(); if (backdrop) backdrop.remove(); menu = backdrop = menuCtx = null; document.removeEventListener('keydown', onMenuKey, true); } function onMenuKey(e) { if (!menu) return; if (e.key === 'Escape') { e.preventDefault(); closeMenu(); } if (e.key === 'Enter' && e.target.closest && e.target.closest('.xf-menu')) { e.preventDefault(); const ok = menu.querySelector('.xf-ok'); if (ok && !ok.disabled) ok.click(); } } function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; } function openMenu(table, idx, anchor) { if (menuCtx && menuCtx.table === table && menuCtx.idx === idx) { closeMenu(); return; } closeMenu(); const ad = adapter(table); if (!ad) return; const cols = columnsOf(table, ad); const col = cols[idx]; if (!col) return; const key = ad.colKey(idx); const current = filters[ad.key] && filters[ad.key][key]; // Values present in rows that pass the other columns' filters (like Excel) const seen = new Set(); let hasBlank = false; for (const tr of dataRows(table)) { 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); } } // "None" (nothing missing) first so the easy devices are one click away; (Blanks) last const values = Array.from(seen).filter(v => v !== 'None' && v !== NO_READING).sort(collator.compare); if (seen.has('None')) values.unshift('None'); if (seen.has(NO_READING)) values.push(NO_READING); if (hasBlank) values.push(BLANK); menuCtx = { table, idx }; const sheet = sheetQuery.matches; menu = el('div', 'xf-menu' + (sheet ? ' xf-sheet' : '')); menu.setAttribute('role', 'dialog'); menu.setAttribute('aria-label', `Sort and filter ${col.label}`); const head = el('div', 'xf-head'); head.appendChild(el('span', 'xf-title', col.label)); const x = el('button', 'xf-x', '×'); x.type = 'button'; x.title = 'Close'; x.onclick = closeMenu; head.appendChild(x); menu.appendChild(head); const item = (svg, text, onClick, disabled) => { const b = el('button', 'xf-item'); b.type = 'button'; b.innerHTML = svg; b.appendChild(el('span', null, text)); b.disabled = !!disabled; b.onclick = onClick; menu.appendChild(b); }; if (col.space) { // Space columns sort by a number, not by text const ns = nativeSort[ad.key]; const cur = ns && ns.col === idx && ns.dir !== 'none' ? `${ns.by}-${ns.dir}` : ''; const opt = (svg, text, by, dir) => { const id = `${by}-${dir}`; item(svg, text, () => { closeMenu(); applySort(ad, idx, cur === id ? 'none' : dir, by); }); if (cur === id) menu.lastChild.classList.add('xf-checked'); }; opt(SORT_ASC_SVG, 'Sort by disk size: smallest first', 'total', 'asc'); opt(SORT_DESC_SVG, 'Sort by disk size: largest first', 'total', 'desc'); opt(SORT_ASC_SVG, 'Sort by free space: least first', 'free', 'asc'); opt(SORT_DESC_SVG, 'Sort by free space: most first', 'free', 'desc'); menu.appendChild(el('div', 'xf-sep')); } else if (col.sortable) { const dir = sortDirOf(ad, idx); if (col.list && /software/i.test(col.label)) { // "Easy" devices first: fewest apps without a documentation article const ns = nativeSort[ad.key]; const cur = ns && ns.col === idx && ns.by === 'docs' && ns.dir !== 'none' ? ns.dir : ''; item(SORT_ASC_SVG, 'Easiest first (fewest undocumented apps)', () => { closeMenu(); applySort(ad, idx, cur === 'asc' ? 'none' : 'asc', 'docs'); }); if (cur === 'asc') menu.lastChild.classList.add('xf-checked'); item(SORT_DESC_SVG, 'Hardest first (most undocumented apps)', () => { closeMenu(); applySort(ad, idx, cur === 'desc' ? 'none' : 'desc', 'docs'); }); if (cur === 'desc') menu.lastChild.classList.add('xf-checked'); } item(SORT_ASC_SVG, 'Sort A to Z', () => { closeMenu(); applySort(ad, idx, dir === 'asc' ? 'none' : 'asc'); }); if (dir === 'asc') menu.lastChild.classList.add('xf-checked'); item(SORT_DESC_SVG, 'Sort Z to A', () => { closeMenu(); applySort(ad, idx, dir === 'desc' ? 'none' : 'desc'); }); if (dir === 'desc') menu.lastChild.classList.add('xf-checked'); menu.appendChild(el('div', 'xf-sep')); } if (col.filterable) { item(CLEAR_SVG, `Clear Filter From "${col.label}"`, () => { if (filters[ad.key]) delete filters[ad.key][key]; closeMenu(); ad.rerun(); }, !current); const search = el('input', 'xf-search'); search.type = 'search'; search.placeholder = 'Search'; if (!col.space) menu.appendChild(search); // Free-space rule (space columns) let freeRule = null; if (col.space) { const box = el('div', 'xf-section'); box.appendChild(el('div', 'xf-section-title', 'Free space')); const gbIn = el('input', 'xf-num'); gbIn.type = 'number'; gbIn.min = '0'; gbIn.step = '1'; gbIn.value = String(current && current.free ? current.free.gb : 20); const radios = []; const radio = (value, labelFn) => { const row = el('label', 'xf-radio'); const r = el('input'); r.type = 'radio'; r.name = 'xf-free'; r.value = value; r.checked = (current && current.free ? current.free.op : 'any') === value; const span = el('span', null, labelFn()); row.appendChild(r); row.appendChild(span); box.appendChild(row); radios.push({ r, span, labelFn }); }; radio('any', () => 'Any amount'); radio('ge', () => `At least ${gbIn.value || 0} GB free`); radio('lt', () => `Less than ${gbIn.value || 0} GB free`); const thr = el('label', 'xf-thr'); thr.appendChild(el('span', null, 'Threshold')); thr.appendChild(gbIn); thr.appendChild(el('span', null, 'GB')); box.appendChild(thr); gbIn.oninput = () => radios.forEach(x => { x.span.textContent = x.labelFn(); }); menu.appendChild(box); menu.appendChild(el('div', 'xf-section-title xf-list-title', 'Disk size')); freeRule = () => { const sel = radios.find(x => x.r.checked); const op = sel ? sel.r.value : 'any'; return op === 'any' ? null : { op, gb: Math.max(0, parseFloat(gbIn.value) || 0) }; }; } // List columns: rows having ANY ticked item, ONLY ticked items (nothing else), or // EXACTLY the ticked items let listMode = null, setMode = null; if (col.list) { const seg = el('div', 'xf-seg xf-seg3'); seg.title = 'Any: at least one ticked item. Only: nothing except ticked items. Exactly: precisely the ticked items, nothing more and nothing less.'; const curMode = !current || !current.only ? 'any' : (current.only === 'subset' ? 'subset' : 'exact'); const btns = {}; setMode = m => { listMode = m; Object.entries(btns).forEach(([k, b]) => b.classList.toggle('on', k === m)); }; [['any', 'Any ticked', 'At least one ticked item'], ['subset', 'Only ticked', 'Nothing except ticked items'], ['exact', 'Exactly ticked', 'Precisely the ticked items']].forEach(([m, text, tip]) => { const b = el('button', 'xf-seg-btn', text); b.type = 'button'; b.title = tip; b.onclick = () => setMode(m); btns[m] = b; seg.appendChild(b); }); setMode(curMode); menu.appendChild(seg); } const list = el('div', 'xf-list'); const mkRow = (label, checked, cls) => { const row = el('label', 'xf-opt' + (cls ? ' ' + cls : '')); const cb = el('input'); cb.type = 'checkbox'; cb.checked = checked; row.appendChild(cb); row.appendChild(el('span', null, label)); 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 => { 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); const actions = el('div', 'xf-actions'); const ok = el('button', 'xf-ok', 'OK'); ok.type = 'button'; const cancel = el('button', 'xf-cancel', 'Cancel'); cancel.type = 'button'; actions.appendChild(ok); actions.appendChild(cancel); menu.appendChild(actions); const visibleOpts = () => opts.filter(o => !o.row.hidden); const sync = () => { const vis = visibleOpts(); const n = vis.filter(o => o.cb.checked).length; all.cb.checked = vis.length > 0 && n === vis.length; all.cb.indeterminate = n > 0 && n < vis.length; ok.disabled = n === 0; }; 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 opts.forEach(o => { o.row.hidden = !!q && !o.v.toLowerCase().includes(q); if (q) o.cb.checked = !o.row.hidden; }); if (!q) opts.forEach(o => { o.cb.checked = !current || current.has(o.v); }); all.row.querySelector('span').textContent = q ? '(Select All Search Results)' : '(Select All)'; sync(); }; ok.onclick = () => { const q = search.value.trim(); const chosen = new Set(visibleOpts().filter(o => o.cb.checked).map(o => o.v)); const free = freeRule ? freeRule() : null; if (free) chosen.free = free; if (listMode === 'subset' || listMode === 'exact') chosen.only = listMode; filters[ad.key] = filters[ad.key] || {}; // everything ticked and no extra rule = no filter if (!q && chosen.size === values.length && !free) delete filters[ad.key][key]; else filters[ad.key][key] = chosen; closeMenu(); ad.rerun(); }; cancel.onclick = closeMenu; sync(); setTimeout(() => { if (!sheet) search.focus(); }, 0); } if (sheet) { backdrop = el('div', 'xf-backdrop'); backdrop.onclick = closeMenu; document.body.appendChild(backdrop); } document.body.appendChild(menu); if (!sheet) positionMenu(anchor); document.addEventListener('keydown', onMenuKey, true); } function positionMenu(anchor) { const r = anchor.getBoundingClientRect(); const w = menu.offsetWidth; const dock = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--dock-h')) || 0; let left = Math.min(r.left, window.innerWidth - w - 8); left = Math.max(8, left); let top = r.bottom + 4; const room = window.innerHeight - dock - top - 8; if (room < 260 && r.top > room) { // not enough room below: open upward menu.style.maxHeight = Math.max(200, r.top - 8 - 60) + 'px'; top = Math.max(60, r.top - 4 - menu.offsetHeight); } else { menu.style.maxHeight = Math.max(200, room) + 'px'; } menu.style.left = left + 'px'; menu.style.top = top + 'px'; } // Close on outside clicks and when the page scrolls under a floating menu document.addEventListener('mousedown', e => { if (menu && !menu.contains(e.target) && !(e.target.closest && e.target.closest('.xf-btn, .xf-chip, .col-filter-btn'))) closeMenu(); }, true); // the card-field dialog stays open through scrolling and the phone keyboard resizing the page const keepOpen = () => menuCtx && menuCtx.fields; window.addEventListener('scroll', () => { if (menu && !keepOpen() && !menu.classList.contains('xf-sheet')) closeMenu(); }, { passive: true }); window.addEventListener('resize', () => { if (!keepOpen()) closeMenu(); }); // ------------------------------------------------------------------------------------ // Custom View card fields (phones/tablets): choose and order what each card shows // ------------------------------------------------------------------------------------ function openFieldEditor() { closeMenu(); if (typeof customColumns === 'undefined') return; // Same frame as the device details dialog: under the nav, full width on phones backdrop = el('div', 'modal-overlay xf-fields-overlay'); backdrop.style.display = 'block'; backdrop.onclick = e => { if (e.target === backdrop) closeMenu(); }; menu = el('div', 'modal-content xf-fields-sheet'); menuCtx = { fields: true }; backdrop.appendChild(menu); document.body.appendChild(backdrop); document.addEventListener('keydown', onMenuKey, true); renderFieldEditor(); } function renderFieldEditor() { if (!menu) return; const label = id => { try { return getCustomColDef(id).label; } catch (e) { return id; } }; menu.innerHTML = ''; const head = el('div', 'modal-header'); const x = el('span', 'close-modal', '\u00d7'); x.title = 'Close'; x.onclick = closeMenu; head.appendChild(x); head.appendChild(el('h2', 'xf-fields-title', 'Card fields')); menu.appendChild(head); menu.appendChild(el('div', 'xf-hint', 'Choose what each card shows and in what order. The first field is the card title.')); const shown = el('div', 'xf-fields'); customColumns.forEach((id, i) => { const row = el('div', 'xf-field'); row.appendChild(el('span', 'xf-field-name', label(id))); if (i === 0) row.appendChild(el('span', 'xf-badge', 'Title')); const btn = (svg, title, fn, disabled, cls) => { const b = el('button', 'xf-mini' + (cls ? ' ' + cls : '')); b.type = 'button'; b.title = title; b.disabled = !!disabled; b.innerHTML = svg; b.setAttribute('aria-label', title); b.onclick = () => { fn(); renderFieldEditor(); }; row.appendChild(b); }; btn('', 'Move up', () => moveCustomColumn(i, i - 1), i === 0); btn('', 'Move down', () => moveCustomColumn(i, i + 1), i === customColumns.length - 1); btn('', 'Remove from cards', () => toggleCustomColumn(id), customColumns.length <= 1, 'xf-mini-x'); shown.appendChild(row); }); menu.appendChild(shown); const addWrap = el('div', 'xf-add'); const search = el('input', 'xf-search'); search.type = 'search'; search.placeholder = 'Add a field...'; addWrap.appendChild(search); const list = el('div', 'xf-list'); getCustomCatalog().forEach(grp => { const avail = grp.ids.filter(id => !customColumns.includes(id)); if (!avail.length) return; const g = el('div', 'xf-group', grp.group); list.appendChild(g); avail.forEach(id => { const b = el('button', 'xf-addfield'); b.type = 'button'; b.appendChild(el('span', null, '+ ' + label(id))); b.onclick = () => { toggleCustomColumn(id); renderFieldEditor(); }; b.dataset.name = label(id).toLowerCase(); list.appendChild(b); }); }); search.oninput = () => { const q = search.value.trim().toLowerCase(); list.querySelectorAll('.xf-addfield').forEach(b => { b.hidden = !!q && !b.dataset.name.includes(q); }); }; addWrap.appendChild(list); menu.appendChild(addWrap); const actions = el('div', 'xf-actions'); const reset = el('button', 'xf-cancel', 'Reset to default'); reset.type = 'button'; reset.onclick = () => { resetCustomColumns(); renderFieldEditor(); }; const done = el('button', 'xf-ok', 'Done'); done.type = 'button'; done.onclick = closeMenu; actions.appendChild(reset); actions.appendChild(done); menu.appendChild(actions); } // ------------------------------------------------------------------------------------ // Hook into the dashboard // ------------------------------------------------------------------------------------ function wrap(name, after) { const orig = window[name]; if (typeof orig !== 'function' || orig.__xf) return; const wrapped = function () { const result = orig.apply(this, arguments); try { after.apply(null, arguments); } catch (e) { console.error('[excel-filter]', e); } return result; }; wrapped.__xf = true; window[name] = wrapped; } function init() { wrap('applyFilters', g => afterFilterPass(document.getElementById(`table-${g}`))); wrap('filterSoftwareTab', () => afterFilterPass(document.getElementById('table-software'))); 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(); } // Clicking a column header uses the dashboard's own sort: drop any size/free-space sort const before = (name, fn) => { const orig = window[name]; if (typeof orig !== 'function' || orig.__xfBefore) return; const w = function () { try { fn.apply(null, arguments); } catch (e) {} return orig.apply(this, arguments); }; w.__xfBefore = true; window[name] = w; }; before('toggleSort', g => { delete nativeSort[g === 'software' ? 'software' : 'group-' + g]; }); before('toggleCustomSort', () => { delete nativeSort.custom; }); // Custom View's own filter icon opens the Excel-style menu instead window.openColFilter = (colId, idx, anchor) => openMenu(document.getElementById('table-custom'), idx, anchor); // Mark tables and add header buttons as they appear (before any filter pass runs) let queued = false; const scan = () => { queued = false; document.querySelectorAll('table.master-table').forEach(t => { const ad = adapter(t); if (!ad) return; t.classList.add('xf-table'); decorate(t, ad); }); }; const schedule = () => { if (!queued) { queued = true; requestAnimationFrame(scan); } }; new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true }); if (cardsQuery.addEventListener) cardsQuery.addEventListener('change', schedule); else cardsQuery.addListener(schedule); schedule(); } // Tables rendered outside the dashboard's own filter passes (the Overview list) call this window.ExcelFilter = { refresh: table => { try { afterFilterPass(table); } catch (e) { console.error('[excel-filter]', e); } } }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();