diff --git a/web/demo/demo.js b/web/demo/demo.js index 10ac60b..3bd8a6c 100644 --- a/web/demo/demo.js +++ b/web/demo/demo.js @@ -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); diff --git a/web/excel-filter.js b/web/excel-filter.js new file mode 100644 index 0000000..b519658 --- /dev/null +++ b/web/excel-filter.js @@ -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 "", 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 + * 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 = ''; + const CARET_SVG = ''; + const ARROW_UP_SVG = ''; + const ARROW_DOWN_SVG = ''; + const SORT_ASC_SVG = ''; + const SORT_DESC_SVG = ''; + const CLEAR_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(''); + 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(``); + }); + if (active.length) parts.push(''); + 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(); +})(); diff --git a/web/index.html b/web/index.html index 129bc96..3b9597c 100755 --- a/web/index.html +++ b/web/index.html @@ -353,6 +353,7 @@ + diff --git a/web/layout.css b/web/layout.css index 922634f..1ef86c6 100644 --- a/web/layout.css +++ b/web/layout.css @@ -1,6 +1,8 @@ /* * Layout additions for the dashboard, loaded after the page's own