/* * 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 checked items. * * 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)$/i; const SKIP_COLUMNS = /(^|\s)apps$/i; // "Apps" button columns have nothing to filter const BLANK = '(Blanks)'; 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, '')); 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 (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), 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)); function cellValues(td, col) { let raw = td ? (td.getAttribute('data-val') != null ? td.getAttribute('data-val') : td.textContent) : ''; raw = norm(raw); 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; if (!cellValues(tr.cells[col.idx], col).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 (ad.native && 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 nativeSortRows(table, cols, s) { const col = cols[s.col]; if (!col) return; const body = table.tBodies[0]; const rows = dataRows(table); rows.sort((a, b) => { 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 = ad.sortDir(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 = ad.sortDir(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); }; } } 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); } } const values = Array.from(seen).sort(collator.compare); 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.sortable) { const dir = ad.sortDir(idx); item(SORT_ASC_SVG, 'Sort A to Z', () => { closeMenu(); ad.sort(idx, dir === 'asc' ? 'none' : 'asc'); }); item(SORT_DESC_SVG, 'Sort Z to A', () => { closeMenu(); ad.sort(idx, dir === 'desc' ? 'none' : 'desc'); }); const marks = menu.querySelectorAll('.xf-item'); if (dir === 'asc') marks[0].classList.add('xf-checked'); if (dir === 'desc') marks[1].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'; menu.appendChild(search); 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 }; }; const all = mkRow('(Select All)', true, 'xf-all'); const opts = values.map(v => ({ v, ...mkRow(v, !current || current.has(v)) })); 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; }); 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)); filters[ad.key] = filters[ad.key] || {}; if (!q && chosen.size === values.length) delete filters[ad.key][key]; // everything ticked = no filter 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); window.addEventListener('scroll', () => { if (menu && !menu.classList.contains('xf-sheet')) closeMenu(); }, { passive: true }); window.addEventListener('resize', closeMenu); // ------------------------------------------------------------------------------------ // Custom View card fields (phones/tablets): choose and order what each card shows // ------------------------------------------------------------------------------------ function openFieldEditor() { closeMenu(); if (typeof customColumns === 'undefined') return; backdrop = el('div', 'xf-backdrop'); backdrop.onclick = closeMenu; menu = el('div', 'xf-menu xf-sheet xf-fields-sheet'); menuCtx = { fields: true }; document.body.appendChild(backdrop); document.body.appendChild(menu); 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', 'xf-head'); head.appendChild(el('span', 'xf-title', 'Card fields')); const x = el('button', 'xf-x', '×'); x.type = 'button'; x.onclick = closeMenu; head.appendChild(x); 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 = (txt, title, fn, disabled) => { const b = el('button', 'xf-mini', txt); b.type = 'button'; b.title = title; b.disabled = !!disabled; 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); 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 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(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();