Files
usmt-dash/web/excel-filter.js
T
Johan Sandoval 9c21567f6d Undocumented apps: per-device filter and wider device column; presence for Skip USMT
- Source Devices in the undocumented-apps list filters by device name, like
  the software columns; Devices and the action column take less room.
- Drop the one-click documented pick (Documented + Only ticked does it).
- Stage legend underline spans label and number.
- Device details title stays on one line; the name truncates instead.
- Presence sweep also pings devices under Skip USMT tags.
2026-10-06 20:49:58 +00:00

801 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 "<column>", 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
* <body> 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 = '<svg viewBox="0 0 512 512" aria-hidden="true"><path fill="currentColor" d="M3.9 54.9C10.5 40.9 24.5 32 40 32H472c15.5 0 29.5 8.9 36.1 22.9s4.6 30.5-5.2 42.5L320 320.9V448c0 12.1-6.8 23.2-17.7 28.6s-23.8 4.3-33.5-3l-64-48c-8.1-6-12.8-15.5-12.8-25.6V320.9L9 97.3C-.7 85.4-2.8 68.8 3.9 54.9z"/></svg>';
const CARET_SVG = '<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 5 6.5 8 3.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const ARROW_UP_SVG = '<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M5 8.5V1.8M2.2 4.5 5 1.5l2.8 3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const ARROW_DOWN_SVG = '<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M5 1.5v6.7M2.2 5.5 5 8.5l2.8-3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const SORT_ASC_SVG = '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M4 2 1 6h2v8h2V6h2L4 2zm5 1h6v2H9V3zm0 4h5v2H9V7zm0 4h4v2H9v-2z"/></svg>';
const SORT_DESC_SVG = '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M4 14 1 10h2V2h2v8h2l-3 4zM9 3h4v2H9V3zm0 4h5v2H9V7zm0 4h6v2H9v-2z"/></svg>';
const CLEAR_SVG = '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M1 2h13l-5 6v5l-3 1V8L1 2zm11.3 7.3 1.4 1.4L12.4 12l1.3 1.3-1.4 1.4L11 13.4l-1.3 1.3-1.4-1.4 1.3-1.3-1.3-1.3 1.4-1.4L11 10.6l1.3-1.3z"/></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('<button type="button" class="xf-chip xf-fields" data-act="fields">&#9776; Card fields</button>');
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' ? ' &uarr;' : dir === 'desc' ? ' &darr;' : '';
parts.push(`<button type="button" class="xf-chip${on ? ' xf-on' : ''}" data-idx="${c.idx}">${on ? FUNNEL_SVG : ''}<span></span>${on ? ` <b>${n}</b>` : ''}${arrow}${CARET_SVG}</button>`);
});
if (active.length) parts.push('<button type="button" class="xf-chip xf-clear" data-act="clear">Clear all</button>');
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);
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 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();
})();