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.
588 lines
29 KiB
JavaScript
588 lines
29 KiB
JavaScript
/*
|
||
* 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">☰ 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' ? ' ↑' : dir === 'desc' ? ' ↓' : '';
|
||
parts.push(`<button type="button" class="xf-chip${on ? ' xf-on' : ''}" data-idx="${c.idx}">${on ? FUNNEL_SVG : ''}<span></span>${on ? ` <b>${n}</b>` : ''}${arrow}${CARET_SVG}</button>`);
|
||
});
|
||
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();
|
||
})();
|