Excel-style column filters, full-page Custom View, card field editor

Filters (web/excel-filter.js), modeled on Excel's AutoFilter:
- Every column header gets a dropdown: Sort A to Z / Z to A, Clear Filter
  From "<column>", Search, a checkbox list of the column's values with
  (Select All) and (Blanks), OK / Cancel. Values come from rows passing
  the other columns' filters; filters on several columns combine. Tags
  and software lists are split into individual items.
- The button shows a funnel when filtered and an arrow when sorted, and a
  "Clear filters (n)" button appears beside the group's copy buttons
- Replaces the old per-column filter row (which also removes the gap
  between header and filter rows); hooks the existing filter passes so
  global search, counts and worker re-renders keep working
- Menus are fixed-position on <body>, so sticky headers and neighbouring
  tables can't cover them; on phones they open as a bottom sheet
- Workbench stacks get the same menus (sorting done client-side)
- Custom View's filter icon opens the same menu

Layout:
- Sticky headers use separated borders so no hairline gap opens
- Phones/tablets: a sticky bar of column chips (with active counts and
  "Clear all") opens the filter menus
- Custom View fills the page like the other views on desktop; it still
  scrolls sideways when wide, with its header pinned under the group header
- Custom View on phones/tablets: a "Card fields" sheet to choose, order
  and remove the fields shown on each card

Demo: first visit puts 6 PCs (one per migration stage) on the Workbench.
This commit is contained in:
Johan Sandoval committed 2026-10-06 17:24:55 +00:00
1 parent d24be4a2d6
commit b216ce43f2
5 files changed
+788 -99

No files matched your search

+29
View File
@@ -937,6 +937,35 @@
let seen = false;
try { seen = localStorage.getItem('usmt_demo_seen') === '1'; } catch (e) {}
if (!seen) this.about(true);
this.seedWorkbench();
},
// First visit: put 6 PCs on the Workbench (one per migration stage) so it isn't empty.
// Only when the visitor has no saved stacks; "Reset my changes" clears them again.
async seedWorkbench() {
try { if (localStorage.getItem('usmt_workbench_stacks')) return; } catch (e) { return; }
for (let i = 0; i < 60; i++) {
if (typeof apiData !== 'undefined' && apiData.filter(r => r["Backup Status"] && r["Backup Status"] !== "No Data").length > 20) break;
await sleep(500);
}
if (typeof apiData === 'undefined' || typeof myStacks === 'undefined') return;
try { if (localStorage.getItem('usmt_workbench_stacks')) return; } catch (e) { return; } // created meanwhile
const wanted = ["Backup In Progress", "Restore In Progress", "Backup Completed", "Completed", "Not Started", "Not Associated"];
const picks = [];
for (const status of wanted) {
const row = apiData.find(r => r["Backup Status"] === status && !picks.includes(r["Source Device"])
&& (status === "Completed" || r["Source Online"] === "Online"));
if (row) picks.push(row["Source Device"]);
}
for (const r of apiData) { // top up to 6 if a stage is empty
if (picks.length >= 6) break;
if (!["Skipped", "No Data"].includes(r["Backup Status"]) && !picks.includes(r["Source Device"])) picks.push(r["Source Device"]);
}
if (!picks.length) return;
myStacks = [{ id: 1, name: "Stack 1", list: picks.slice(0, 6).join('\n') }];
saveAndRender();
myStacks.forEach(st => refreshStackTable(st.id));
},
about(show) {
this.modal.classList.toggle('show', show);
+587
View File
@@ -0,0 +1,587 @@
/*
* 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 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
* <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)$/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 = '<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 (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 has its own filter icon, rerouted to our menu)
if (ad.custom) {
cols.forEach(c => {
const icon = c.th.querySelector('.col-filter-btn');
if (icon) icon.classList.toggle('xf-on', activeKeys.has(ad.colKey(c.idx)));
});
} else {
cols.forEach(c => {
if (!c.filterable && !c.sortable) return;
let btn = c.th.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); });
c.th.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('<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 = ad.sortDir(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);
};
}
}
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();
})();
+1
View File
@@ -353,6 +353,7 @@
</style>
<link rel="stylesheet" href="/layout.css">
<script src="/layout.js"></script>
<script src="/excel-filter.js" defer></script>
</head>
<body>
+152 -71
View File
@@ -1,6 +1,8 @@
/*
* Layout additions for the dashboard, loaded after the page's own <style> block:
* - sticky status-group headers (all sizes) and sticky column headers (desktop)
* - sticky status-group headers and table headers
* - Custom View filling the page on desktop
* - Excel-style filter menus, filter chip bar and card-field editor (excel-filter.js)
* - card view for tables on phones and tablets
* - small-screen fixes for forms, docs and dialogs
* layout.js measures the sticky offsets and labels card cells.
@@ -10,28 +12,35 @@
* Sticky headers
*
* A status group's header ("Not Started (37)") sticks under the top nav while you scroll
* through that group, then hands over to the next group's header. Beneath it the table's
* header (column names + filters on desktop, the filter panel on phones) sticks as one
* block, so the rows inside it can never drift apart. layout.js sets --nav-h and, per
* table, --sticky-top (nav + group header).
* through that group, then hands over to the next group's header. On desktop the column
* header sticks beneath it. layout.js sets --nav-h and, per table, --sticky-top
* (nav + group header).
* ------------------------------------------------------------------------------------- */
.group-header { position: sticky; top: var(--nav-h, 60px); z-index: 30; }
/* The old filter row (text boxes and fixed lists) is replaced by Excel-style column menus */
table.xf-table thead tr.filter-row { display: none !important; }
.master-table:not(#table-custom) thead { position: sticky; top: var(--sticky-top, 112px); z-index: 20; }
.master-table:not(#table-custom) thead tr.filter-row th {
/* the original header tint, but opaque so rows don't show through */
background: linear-gradient(rgba(0,0,0,0.2), rgba(0,0,0,0.2)), var(--bg-card);
}
@media (min-width: 1101px) {
/* An overflowing wrapper would capture the sticky header; these tables never need to
scroll sideways at desktop widths (they're fixed-layout, 100% wide) */
#masterList .table-wrapper, #software .table-wrapper, .stack-box.table-wrapper { overflow: visible !important; }
/* Custom View scrolls inside its own box (it can be wider than the page), so its
group header stays put and its column header sticks to the top of that box */
#wrapper-custom .group-header { position: static; }
#scroll-custom thead th { position: sticky; top: 0; z-index: 20; }
/* Collapsed table borders stay behind when a header sticks, leaving a hairline gap
that rows show through; separated borders move with their cells */
.master-table { border-collapse: separate; border-spacing: 0; }
/* Custom View fills the page like the other views. It still scrolls sideways when its
columns are wider than the page; its header is pinned by layout.js (--pin-y) since a
sideways-scrolling box can't hold a sticky header */
#custom, #wrapper-custom { overflow: visible !important; }
#scroll-custom { max-height: none !important; overflow-x: auto; overflow-y: hidden; }
#table-custom thead th {
position: relative !important; top: auto !important; z-index: 20;
transform: translateY(var(--pin-y, 0px));
}
}
/* ---------------------------------------------------------------------------------------
@@ -57,9 +66,9 @@ body { padding-bottom: var(--dock-h, 40px) !important; }
* An 11-column table can't be read on a phone or an iPad in portrait, and sideways
* scrolling only ever shows a few columns. Below 1100px (where the nav also collapses)
* each row becomes a card: the device name as its title and every other column as a
* "label: value" line. mobile.js copies the column names onto the cells. Cards flow one
* per row on phones and two or more on tablets. The column filter row becomes a filter
* panel above the cards.
* "label: value" line. layout.js copies the column names onto the cells. Cards flow one
* per row on phones and two or more on tablets. A sticky bar of column chips above the
* cards opens the same Excel-style filter menus as the desktop headers.
* ------------------------------------------------------------------------------------- */
@media (max-width: 1100px) {
.table-wrapper { overflow-x: visible !important; }
@@ -73,39 +82,13 @@ body { padding-bottom: var(--dock-h, 40px) !important; }
table.master-table thead tr.card-label-row,
table.master-table thead tr.card-aux-row { display: none; }
/* Filters: a wrapping panel of labeled controls */
table.master-table thead tr.filter-row {
display: flex; flex-wrap: wrap; gap: 8px 10px; padding: 10px 12px;
/* opaque: the panel sticks over the cards while scrolling */
background: linear-gradient(rgba(0,0,0,0.15), rgba(0,0,0,0.15)), var(--bg-card);
border-bottom: 1px solid var(--border-color); box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
table.master-table thead tr.filter-row th {
display: block; flex: 1 1 150px; min-width: 0; width: auto !important;
padding: 0; border: 0; background: transparent;
}
table.master-table thead tr.filter-row th:empty,
table.master-table thead tr.filter-row th[data-label=""] { display: none; }
table.master-table thead tr.filter-row th::before {
content: attr(data-label); display: block; margin-bottom: 3px;
font-size: 0.78em; font-weight: 600; color: var(--text-muted);
}
/* Column names live on the cards; the header is replaced by the filter chip bar */
table.xf-table thead { display: none !important; }
/* Custom View: cards don't need the sideways-scrolling box, so it scrolls with the
page like the other tables and its header strip sticks under the group header */
#wrapper-custom, #scroll-custom { overflow: visible !important; max-height: none !important; }
#table-custom thead { position: sticky; top: var(--sticky-top, 112px); z-index: 20; }
/* Custom View keeps its header (sort / filter / remove buttons) as a strip of chips */
#table-custom thead tr.card-label-row {
display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 12px;
background: var(--table-header-bg);
}
#table-custom thead tr.card-label-row th {
display: flex; align-items: center; gap: 4px; width: auto !important; min-width: 0 !important;
padding: 4px 8px; border: 1px solid rgba(255,255,255,0.2); border-radius: 999px;
position: static; white-space: nowrap;
}
/* Custom View scrolls with the page like the other views */
#custom, #wrapper-custom, #scroll-custom { overflow: visible !important; max-height: none !important; }
/* Desktop column tools don't apply to cards ("Card fields" replaces them) */
#custom button[onclick*="shrinkCustomColumns"], #custom [onclick*="toggleAddColMenu"] { display: none !important; }
/* Rows -> cards */
table.master-table tbody {
@@ -146,30 +129,8 @@ body { padding-bottom: var(--dock-h, 40px) !important; }
/* "No devices" style rows span the full card */
table.master-table tbody td[colspan] { display: block; text-align: center; border: 0; padding: 6px 0; }
/* Filters fold away behind a button (added by mobile.js) */
.card-filter-toggle {
display: inline-flex; align-items: center; gap: 6px; margin: 10px 12px 0;
background: transparent; color: var(--text-main); border: 1px solid var(--border-color);
border-radius: 6px; padding: 6px 12px; font: inherit; font-size: 0.85em; cursor: pointer;
}
.card-filter-toggle .count {
background: #3498db; color: #fff; border-radius: 999px; padding: 0 7px; font-size: 0.85em;
}
table.master-table:not(.show-filters) thead tr.filter-row { display: none; }
table.master-table.show-filters thead tr.filter-row { margin-top: 10px; }
}
@media (min-width: 1101px) {
.card-filter-toggle { display: none; }
}
@media (max-width: 600px) {
/* Compact filter panel on phones: three per row, shorter controls */
table.master-table thead tr.filter-row { gap: 6px 8px; padding: 8px 10px; }
table.master-table thead tr.filter-row th { flex: 1 1 96px; }
table.master-table thead tr.filter-row th::before { font-size: 0.7em; margin-bottom: 2px; }
table.master-table thead tr.filter-row input[type="text"],
table.master-table thead tr.filter-row .select-box { height: 32px; padding: 3px 6px; }
table.master-table thead tr.filter-row .select-box { font-size: 13px; }
}
@media (max-width: 420px) {
table.master-table tbody td { padding-left: 7.8em; }
table.master-table tbody td::before { width: 7.2em; }
@@ -238,3 +199,123 @@ body { padding-bottom: var(--dock-h, 40px) !important; }
.group-header { font-size: 0.98em; padding: 8px 10px; }
.modal-content { margin: 66px 6px 6px !important; max-height: calc(100dvh - 72px) !important; }
}
/* ---------------------------------------------------------------------------------------
* Excel-style filters (excel-filter.js)
* ------------------------------------------------------------------------------------- */
/* Header dropdown button: takes the old sort icon's spot at the right of the header, next
to the copy-column icon (sorting is in the menu, and the button shows the sort arrow) */
table.xf-table thead th > .sort-icon { display: none !important; }
table.xf-table thead th > .xf-btn { position: absolute; right: 22px; top: 50%; transform: translateY(-50%); margin: 0; }
table.xf-table thead th:has(> .xf-btn):not(.sortable-th) { position: relative; padding-right: 40px !important; }
.xf-btn {
display: inline-flex; align-items: center; justify-content: center; vertical-align: middle;
width: 17px; height: 17px; margin-left: 4px; padding: 0; cursor: pointer;
border: 1px solid rgba(255,255,255,0.35); border-radius: 4px;
background: rgba(255,255,255,0.08); color: inherit;
}
.xf-btn:hover { background: rgba(255,255,255,0.22); }
.xf-btn svg { width: 10px; height: 10px; }
.xf-btn.xf-on { background: #f1c40f; border-color: #f1c40f; color: #1f2937; }
.xf-btn.xf-sorted { background: rgba(255,255,255,0.28); border-color: rgba(255,255,255,0.6); }
.col-filter-btn.xf-on { color: #f1c40f !important; opacity: 1 !important; }
/* Dropdown menu (desktop) / bottom sheet (phones) */
.xf-menu {
position: fixed; z-index: 2600; width: 290px; display: flex; flex-direction: column;
padding: 6px 0; overflow: hidden; font-size: 14px; text-align: left;
background: var(--bg-card); color: var(--text-main);
border: 1px solid var(--border-color); border-radius: 8px; box-shadow: 0 12px 32px rgba(0,0,0,0.5);
}
.xf-head { display: none; }
.xf-item {
display: flex; align-items: center; gap: 10px; padding: 7px 14px; cursor: pointer;
background: none; border: 0; color: inherit; font: inherit; text-align: left;
}
.xf-item:hover:not(:disabled) { background: var(--hover-bg); }
.xf-item:disabled { opacity: 0.45; cursor: default; }
.xf-item svg { width: 14px; height: 14px; flex: none; }
.xf-item.xf-checked { color: #3498db; font-weight: 700; }
.xf-sep { height: 1px; margin: 4px 0; background: var(--border-light); }
.xf-search {
margin: 6px 12px; padding: 7px 9px; font: inherit; color: var(--text-main);
background: var(--input-bg); border: 1px solid var(--border-color); border-radius: 5px;
}
.xf-list {
flex: 1 1 auto; min-height: 80px; max-height: 260px; overflow-y: auto;
margin: 0 12px; padding: 4px 0; border: 1px solid var(--border-light); border-radius: 5px;
}
.xf-opt { display: flex; align-items: center; gap: 8px; padding: 4px 10px; cursor: pointer; white-space: nowrap; }
.xf-opt span { overflow: hidden; text-overflow: ellipsis; }
.xf-opt:hover { background: var(--hover-bg); }
.xf-opt[hidden] { display: none; }
.xf-all { font-weight: 600; border-bottom: 1px solid var(--border-light); }
.xf-empty { padding: 8px 10px; color: var(--text-muted); }
.xf-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 10px 12px 6px; }
.xf-actions button {
min-width: 76px; padding: 6px 12px; cursor: pointer; font: inherit;
background: transparent; color: inherit; border: 1px solid var(--border-color); border-radius: 5px;
}
.xf-actions .xf-ok { background: #3498db; border-color: #3498db; color: #fff; font-weight: 600; }
.xf-actions .xf-ok:disabled { opacity: 0.5; cursor: default; }
.xf-backdrop { position: fixed; inset: 0; z-index: 2590; background: rgba(0,0,0,0.5); }
.xf-menu.xf-sheet {
left: 0 !important; right: 0; bottom: 0; top: auto !important; margin: 0 auto;
width: auto; max-width: 560px; max-height: 82vh !important; max-height: 82dvh !important;
border-radius: 14px 14px 0 0; padding-bottom: calc(6px + env(safe-area-inset-bottom));
font-size: 15px;
}
.xf-sheet .xf-head {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 16px 10px; margin-bottom: 4px; font-weight: 700; font-size: 1.05em;
border-bottom: 1px solid var(--border-light);
}
.xf-x { background: none; border: 0; color: inherit; font-size: 1.7em; line-height: 1; cursor: pointer; padding: 0 4px; }
.xf-sheet .xf-item { padding: 11px 16px; }
.xf-sheet .xf-opt { padding: 9px 12px; }
.xf-sheet .xf-list { max-height: none; }
.xf-sheet input[type="checkbox"] { width: 18px; height: 18px; }
.xf-sheet .xf-actions button { flex: 1; padding: 11px; }
/* Filter chip bar (cards) */
.xf-bar { display: none; }
@media (max-width: 1100px) {
.xf-bar {
display: flex; gap: 6px; padding: 8px 10px; overflow-x: auto; scrollbar-width: none;
position: sticky; top: var(--sticky-top, 112px); z-index: 25;
background: linear-gradient(rgba(0,0,0,0.15), rgba(0,0,0,0.15)), var(--bg-card);
border-bottom: 1px solid var(--border-color); box-shadow: 0 4px 8px rgba(0,0,0,0.25);
}
.xf-bar::-webkit-scrollbar { display: none; }
}
.xf-chip {
display: inline-flex; align-items: center; gap: 5px; flex: none; padding: 6px 11px; cursor: pointer;
font: inherit; font-size: 0.82em; white-space: nowrap;
background: var(--bg-body); color: var(--text-main); border: 1px solid var(--border-color); border-radius: 999px;
}
.xf-chip svg { width: 10px; height: 10px; }
.xf-chip.xf-on { background: #f1c40f; border-color: #f1c40f; color: #1f2937; font-weight: 600; }
.xf-chip.xf-clear { color: #e74c3c; border-color: #e74c3c; }
.xf-chip.xf-fields { background: #3498db; border-color: #3498db; color: #fff; font-weight: 600; }
/* Custom View card-field editor */
.xf-hint { padding: 0 16px 8px; font-size: 0.85em; color: var(--text-muted); }
.xf-fields { flex: 0 0 auto; max-height: 34vh; overflow-y: auto; padding: 0 12px; } /* never squeezed by the add list */
.xf-field { display: flex; align-items: center; gap: 8px; padding: 6px 4px; border-bottom: 1px solid var(--border-light); }
.xf-field-name { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.xf-badge { font-size: 0.7em; padding: 2px 8px; border-radius: 999px; background: #3498db; color: #fff; }
.xf-mini {
width: 36px; height: 36px; flex: none; cursor: pointer; font-size: 1.1em;
background: transparent; color: inherit; border: 1px solid var(--border-color); border-radius: 6px;
}
.xf-mini:disabled { opacity: 0.3; cursor: default; }
.xf-add { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; margin-top: 8px; }
.xf-fields-sheet .xf-list { flex: 1 1 auto; min-height: 110px; }
.xf-fields-sheet .xf-add .xf-search { flex: none; }
.xf-group { padding: 10px 10px 4px; font-size: 0.75em; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; color: var(--text-muted); }
.xf-addfield { display: block; width: 100%; padding: 8px 10px; text-align: left; cursor: pointer; background: none; border: 0; color: #3498db; font: inherit; }
.xf-addfield:hover { background: var(--hover-bg); }
.xf-addfield[hidden] { display: none; }
.xf-clear-all { white-space: nowrap; color: #f1c40f !important; border-color: rgba(241,196,15,0.6) !important; }
.xf-clear-all svg { width: 14px; height: 14px; flex: none; }
+19 -28
View File
@@ -45,33 +45,6 @@
thead.querySelectorAll('tr.filter-row').forEach(apply);
for (const body of table.tBodies) for (const row of body.rows) apply(row);
addFilterToggle(table, thead.querySelector('tr.filter-row'));
}
// Column filters take a lot of room above every card group, so they fold behind a
// button that also shows how many filters are active.
function addFilterToggle(table, filterRow) {
if (!filterRow) return;
let btn = table.previousElementSibling;
if (!btn || !btn.classList.contains('card-filter-toggle')) {
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'card-filter-toggle';
btn.addEventListener('click', () => { table.classList.toggle('show-filters'); updateToggle(table, btn); });
table.parentNode.insertBefore(btn, table);
filterRow.addEventListener('input', () => updateToggle(table, btn));
filterRow.addEventListener('change', () => updateToggle(table, btn));
}
updateToggle(table, btn);
}
function updateToggle(table, btn) {
const row = table.tHead && table.tHead.querySelector('tr.filter-row');
if (!row) return;
const active = Array.from(row.querySelectorAll('input[type="text"]')).filter(i => i.value.trim()).length
+ row.querySelectorAll('input[type="checkbox"]:checked').length;
const open = table.classList.contains('show-filters');
const html = `${open ? 'Hide' : 'Show'} filters${active ? ` <span class="count">${active}</span>` : ''}`;
if (btn.innerHTML !== html) btn.innerHTML = html;
}
// --- Sticky offsets ---------------------------------------------------------------
@@ -99,8 +72,25 @@
const gh = groupHeaderFor(table);
const ghH = gh && getComputedStyle(gh).position === 'sticky' ? gh.getBoundingClientRect().height : 0;
// 1px overlap tucks the table header under the group header: no hairline gap
setVar(table, '--sticky-top', Math.max(0, navH + ghH - 1) + 'px');
const top = Math.max(0, navH + ghH - 1) + 'px';
setVar(table, '--sticky-top', top);
if (table.parentElement) setVar(table.parentElement, '--sticky-top', top); // for the filter chip bar
});
pinCustomHeader();
}
// Custom View can be wider than the page, so it scrolls sideways in its own box, and a
// sideways-scrolling box can't contain a position:sticky header. Instead its header is
// shifted down by exactly the amount needed to stay pinned under the group header.
function pinCustomHeader() {
const table = document.getElementById('table-custom');
if (!table) return;
if (cardsQuery.matches || !table.offsetParent || !table.tHead) { setVar(table, '--pin-y', '0px'); return; }
const top = parseFloat(table.style.getPropertyValue('--sticky-top')) || 0;
const r = table.getBoundingClientRect();
const headH = table.tHead.getBoundingClientRect().height;
const y = Math.max(0, Math.min(top - r.top, r.height - headH));
setVar(table, '--pin-y', y + 'px');
}
let queued = false;
@@ -124,6 +114,7 @@
// Groups and pages are shown by toggling display (not a DOM change), so also
// re-measure as the user scrolls; writes only happen when a value changes.
window.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('scroll', pinCustomHeader, { passive: true }); // same frame: no lag
// The terminal dock opens, closes and resizes without a page resize
if (window.ResizeObserver) {
const watchDock = () => {