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

+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();
})();