Files
usmt-dash/web/excel-filter.js
T
Johan Sandoval b216ce43f2 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.
2026-10-06 17:24:55 +00:00

588 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Excel-style column filters for every dashboard table.
*
* Modeled on Excel's AutoFilter: each column header gets a dropdown with
* Sort A to Z / Sort Z to A, Clear Filter From "<column>", a Search box, and a checkbox
* list of every value in the column with (Select All) and (Blanks), then OK / Cancel.
* The value list only shows values from rows that pass the OTHER columns' filters, filters
* on several columns combine (AND), and a filtered column's button shows a funnel.
* List-type columns (Tags, Missing/Specialty Software) are split so each tag or app is
* its own checkbox; a row matches if it has any of the 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();
})();