Live-ghost column drag; "only" app filter; disk size / free space filters

Custom View column drag rewritten with pointer events (replacing native
drag-and-drop): grab a column by its grip or anywhere on its header (a
click still sorts); the real column slides into the slot it would land
in as a translucent striped ghost while a label follows the pointer.
Only the horizontal position decides the slot. Esc cancels; data
refreshes wait until the drop; wide tables scroll near the edges.

Filters:
- List columns (software, tags): "Has any ticked" / "Has only ticked"
  toggle - e.g. devices missing only Adobe Acrobat Pro
- Space columns: filter by disk size (221 GB, 442 GB, no reading) plus a
  free-space rule (any / at least N GB / less than N GB, default 20); sort
  by disk size or by free space (numeric, no reading last); header clicks
  cancel these sorts
- Menu controls no longer shrink when the menu is height-limited
This commit is contained in:
Johan Sandoval committed 2026-10-06 18:20:00 +00:00
1 parent 973ff592a4
commit 50c2a18a68
3 files changed
+277 -92

No files matched your search

+140 -17
View File
@@ -7,7 +7,9 @@
* 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.
* its own checkbox; a row matches if it has ANY of the ticked items, or with "Has only
* ticked" if it has nothing BUT ticked items. 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
@@ -23,6 +25,8 @@
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 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 }
@@ -111,6 +115,7 @@
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)
};
@@ -120,9 +125,17 @@
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) {
let raw = td ? (td.getAttribute('data-val') != null ? td.getAttribute('data-val') : td.textContent) : '';
raw = norm(raw);
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);
@@ -146,7 +159,15 @@
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;
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) { if (!vals.every(v => allowed.has(v))) return false; }
else if (!vals.some(v => allowed.has(v))) return false;
}
return true;
}
@@ -160,7 +181,7 @@
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]);
if (nativeSort[ad.key] && nativeSort[ad.key].dir !== 'none') nativeSortRows(table, cols, nativeSort[ad.key]);
let visible = 0;
for (const tr of dataRows(table)) {
@@ -186,7 +207,13 @@
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; };
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;
});
@@ -215,7 +242,7 @@
}
// 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 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');
@@ -261,7 +288,7 @@
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 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>`);
});
@@ -284,6 +311,23 @@
}
}
// 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');
@@ -335,8 +379,9 @@
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').sort(collator.compare);
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 };
@@ -357,13 +402,26 @@
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');
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);
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'));
}
@@ -374,7 +432,58 @@
}, !current);
const search = el('input', 'xf-search'); search.type = 'search'; search.placeholder = 'Search';
menu.appendChild(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: match rows having ANY ticked item, or ONLY ticked items
let onlyToggle = null;
if (col.list) {
const seg = el('div', 'xf-seg');
seg.title = 'Any: devices with at least one ticked item. Only: devices with nothing except ticked items.';
const mk = (text, only) => {
const b = el('button', 'xf-seg-btn', text); b.type = 'button';
b.onclick = () => { seg.querySelectorAll('.xf-seg-btn').forEach(x => x.classList.toggle('on', x === b)); };
if (!!(current && current.only) === only) b.classList.add('on');
seg.appendChild(b);
return b;
};
mk('Has any ticked', false);
onlyToggle = mk('Has only ticked', true);
menu.appendChild(seg);
}
const list = el('div', 'xf-list');
const mkRow = (label, checked, cls) => {
@@ -455,8 +564,12 @@
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 (onlyToggle && onlyToggle.classList.contains('on')) chosen.only = true;
filters[ad.key] = filters[ad.key] || {};
if (!q && chosen.size === values.length) delete filters[ad.key][key]; // everything ticked = no filter
// 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();
};
@@ -602,6 +715,16 @@
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);