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:
1 parent
973ff592a4
commit
50c2a18a68
3 files changed
+277
-92
No files matched your search
+140
-17
@@ -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' ? ' ↑' : dir === 'desc' ? ' ↓' : '';
|
||||
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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user