- Fix: sorting a software column stopped its filter listing apps individually (the sort arrow written into the header changed the label used to recognise list columns) - "None" (nothing missing) is pinned to the top of the value list, so the easy devices are one click away - Software columns get "Documented" / "Undocumented" quick picks that tick only apps with / without a knowledge-base article; documented apps are marked in the list - Custom View: remove Reset Filters / Reset Sort (Clear filters and the column menus replace them); old saved filters are cleared since nothing else could clear them - Custom View column drag: a ghost of the column follows the cursor, the column being moved is dimmed, and a line shows where it will land
252 lines
12 KiB
JavaScript
252 lines
12 KiB
JavaScript
/*
|
|
* Layout helpers (see layout.css).
|
|
*
|
|
* Sticky headers: measures the top nav and each table's group header so the column
|
|
* header rows can stick directly beneath them (--nav-h, --sticky-top, --thead-r1).
|
|
*
|
|
* Card view: on phones and tablets each table row is shown as a card, with every cell
|
|
* rendered as "Column name: value". CSS can't look up a cell's column header, so this
|
|
* copies each header's text onto its cells as data-label.
|
|
*
|
|
* The dashboard re-renders its tables constantly, so both are reapplied (once per frame
|
|
* at most) whenever the DOM changes.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const cardsQuery = window.matchMedia('(max-width: 1100px)');
|
|
const clean = text => text.replace(/[\u2195\u25B2\u25BC\u00D7\u2715]/g, '').replace(/\s+/g, ' ').trim();
|
|
|
|
function labelTable(table) {
|
|
const thead = table.tHead;
|
|
if (!thead) return;
|
|
|
|
// The header row is the one with the most text (Custom View has a drag-handle row above it)
|
|
let headerRow = null, best = -1;
|
|
for (const row of thead.rows) {
|
|
if (row.classList.contains('filter-row')) continue;
|
|
const named = Array.from(row.cells).filter(c => clean(c.textContent)).length;
|
|
if (named > best) { best = named; headerRow = row; }
|
|
}
|
|
if (!headerRow) return;
|
|
|
|
for (const row of thead.rows) {
|
|
if (row === headerRow) row.classList.add('card-label-row');
|
|
else if (!row.classList.contains('filter-row')) row.classList.add('card-aux-row');
|
|
}
|
|
|
|
const labels = Array.from(headerRow.cells).map(c => clean(c.textContent));
|
|
const apply = row => {
|
|
Array.from(row.cells).forEach((cell, i) => {
|
|
const label = cell.colSpan > 1 ? '' : (labels[i] || '');
|
|
if (cell.dataset.label !== label) cell.dataset.label = label;
|
|
});
|
|
};
|
|
thead.querySelectorAll('tr.filter-row').forEach(apply);
|
|
for (const body of table.tBodies) for (const row of body.rows) apply(row);
|
|
|
|
}
|
|
|
|
// --- Sticky offsets ---------------------------------------------------------------
|
|
function setVar(el, name, value) {
|
|
if (el.style.getPropertyValue(name) !== value) el.style.setProperty(name, value);
|
|
}
|
|
function groupHeaderFor(table) {
|
|
// The group header is a sibling of the table or of its wrapper
|
|
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;
|
|
}
|
|
function updateSticky() {
|
|
const root = document.documentElement;
|
|
const nav = document.querySelector('.top-nav');
|
|
const navH = nav ? nav.getBoundingClientRect().height : 60;
|
|
setVar(root, '--nav-h', navH + 'px');
|
|
const dock = document.getElementById('usmt-term');
|
|
setVar(root, '--dock-h', (dock ? Math.ceil(dock.getBoundingClientRect().height) : 0) + 'px');
|
|
|
|
document.querySelectorAll('table.master-table').forEach(table => {
|
|
if (!table.offsetParent) return; // hidden page/group: keep old values
|
|
const gh = groupHeaderFor(table);
|
|
const ghH = gh && getComputedStyle(gh).position === 'sticky' ? gh.getBoundingClientRect().height : 0;
|
|
// 1px overlap tucks the table header under the group header: no hairline gap
|
|
const top = Math.max(0, navH + ghH - 1) + 'px';
|
|
setVar(table, '--sticky-top', top);
|
|
if (table.parentElement) setVar(table.parentElement, '--sticky-top', top); // for the filter chip bar
|
|
});
|
|
setupCustomHScroll();
|
|
}
|
|
|
|
// Custom View can be wider than the page. A sideways-scrolling box would stop its header
|
|
// from sticking, so the box clips instead (overflow-x: clip) and the table is shifted
|
|
// sideways by a scrollbar pinned to the bottom of the window. Wheel, shift+wheel and
|
|
// touch swipes over the table drive the same scrollbar.
|
|
function setupCustomHScroll() {
|
|
const box = document.getElementById('scroll-custom');
|
|
const host = document.getElementById('custom-table-host');
|
|
const table = document.getElementById('table-custom');
|
|
if (!box || !host || !table) return;
|
|
let bar = document.getElementById('custom-hscroll');
|
|
if (!bar) {
|
|
bar = document.createElement('div');
|
|
bar.id = 'custom-hscroll';
|
|
bar.className = 'custom-hscroll no-copy';
|
|
bar.appendChild(document.createElement('div'));
|
|
box.insertAdjacentElement('afterend', bar);
|
|
const applyX = () => { host.style.transform = bar.scrollLeft ? `translateX(${-bar.scrollLeft}px)` : ''; };
|
|
bar.addEventListener('scroll', applyX, { passive: true });
|
|
box.addEventListener('wheel', e => {
|
|
if (bar.hidden) return;
|
|
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey ? e.deltaY : 0);
|
|
if (!dx) return;
|
|
e.preventDefault();
|
|
bar.scrollLeft += dx;
|
|
}, { passive: false });
|
|
let start = null;
|
|
box.addEventListener('touchstart', e => {
|
|
if (bar.hidden || e.touches.length !== 1) { start = null; return; }
|
|
start = { x: e.touches[0].clientX, y: e.touches[0].clientY, left: bar.scrollLeft, lock: null };
|
|
}, { passive: true });
|
|
box.addEventListener('touchmove', e => {
|
|
if (!start) return;
|
|
const dx = e.touches[0].clientX - start.x, dy = e.touches[0].clientY - start.y;
|
|
if (!start.lock && Math.hypot(dx, dy) > 8) start.lock = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';
|
|
if (start.lock === 'x') { e.preventDefault(); bar.scrollLeft = start.left - dx; }
|
|
}, { passive: false });
|
|
box.addEventListener('touchend', () => { start = null; }, { passive: true });
|
|
}
|
|
if (cardsQuery.matches || !table.offsetParent) {
|
|
bar.hidden = true;
|
|
if (host.style.transform) host.style.transform = '';
|
|
return;
|
|
}
|
|
const full = Math.ceil(table.getBoundingClientRect().width);
|
|
const view = box.clientWidth;
|
|
const inner = bar.firstChild;
|
|
if (inner.style.width !== full + 'px') inner.style.width = full + 'px';
|
|
const hide = full <= view + 1;
|
|
if (bar.hidden !== hide) bar.hidden = hide;
|
|
if (hide && bar.scrollLeft) { bar.scrollLeft = 0; host.style.transform = ''; }
|
|
}
|
|
|
|
// Auto-sizing text boxes (Workbench stacks) only grow while typing in the original;
|
|
// size them to their contents whenever they're shown or re-rendered too.
|
|
function autosizeTextareas() {
|
|
document.querySelectorAll('textarea.auto-resize').forEach(t => {
|
|
if (!t.offsetParent || document.activeElement === t) return;
|
|
if (Math.abs(t.scrollHeight - t.clientHeight) <= 2) return;
|
|
t.style.height = 'auto';
|
|
t.style.height = t.scrollHeight + 'px';
|
|
});
|
|
}
|
|
|
|
// Custom View column drag: a ghost of the column follows the cursor, the column being
|
|
// moved is dimmed, and a line shows exactly where it will land. Adds to the dashboard's
|
|
// own drag-and-drop (attachColDragHandlers), which still performs the move.
|
|
function enhanceColumnDrag() {
|
|
const table = document.getElementById('table-custom');
|
|
const host = document.getElementById('custom-table-host');
|
|
if (!table || !host) return;
|
|
const headers = Array.from(table.querySelectorAll('thead th.custom-th'));
|
|
let from = null, marker = null;
|
|
|
|
const setDimmed = (idx, on) => {
|
|
for (const row of table.rows) { const c = row.cells[idx]; if (c) c.classList.toggle('col-moving', on); }
|
|
};
|
|
const clear = () => {
|
|
if (from !== null) setDimmed(from, false);
|
|
if (marker) marker.remove();
|
|
marker = null; from = null;
|
|
};
|
|
|
|
headers.forEach((th, idx) => {
|
|
const grip = th.querySelector('.col-grip');
|
|
if (!grip || grip.dataset.ghost) return;
|
|
grip.dataset.ghost = '1';
|
|
|
|
grip.addEventListener('dragstart', e => {
|
|
from = idx;
|
|
const ghost = document.createElement('div');
|
|
ghost.className = 'col-drag-ghost';
|
|
const head = document.createElement('div');
|
|
head.className = 'col-drag-ghost-head';
|
|
head.textContent = th.querySelector('.label-text') ? th.querySelector('.label-text').textContent : th.textContent.trim();
|
|
ghost.appendChild(head);
|
|
Array.from(table.tBodies[0].rows).filter(r => r.style.display !== 'none').slice(0, 7).forEach(r => {
|
|
const cell = document.createElement('div');
|
|
cell.className = 'col-drag-ghost-cell';
|
|
cell.textContent = r.cells[idx] ? r.cells[idx].textContent.trim() : '';
|
|
ghost.appendChild(cell);
|
|
});
|
|
ghost.style.width = Math.min(280, Math.max(140, th.getBoundingClientRect().width)) + 'px';
|
|
document.body.appendChild(ghost);
|
|
try { e.dataTransfer.setDragImage(ghost, 24, 18); } catch (_) {}
|
|
setTimeout(() => ghost.remove(), 0);
|
|
setDimmed(idx, true);
|
|
});
|
|
grip.addEventListener('dragend', clear);
|
|
|
|
th.addEventListener('dragover', () => {
|
|
if (from === null) return;
|
|
if (!marker) { marker = document.createElement('div'); marker.className = 'col-drop-marker'; host.appendChild(marker); }
|
|
// moveCustomColumn(from, to) lands the column after the target when moving right,
|
|
// before it when moving left
|
|
const hostBox = host.getBoundingClientRect(), box = th.getBoundingClientRect();
|
|
const x = (idx > from ? box.right : box.left) - hostBox.left;
|
|
marker.style.left = (x - 2) + 'px';
|
|
marker.style.height = table.getBoundingClientRect().height + 'px';
|
|
marker.hidden = idx === from;
|
|
});
|
|
th.addEventListener('drop', clear);
|
|
});
|
|
}
|
|
|
|
let queued = false;
|
|
function run() {
|
|
queued = false;
|
|
updateSticky();
|
|
autosizeTextareas();
|
|
if (!cardsQuery.matches) return;
|
|
document.querySelectorAll('table.master-table').forEach(labelTable);
|
|
}
|
|
function schedule() {
|
|
if (queued) return;
|
|
queued = true;
|
|
requestAnimationFrame(run);
|
|
}
|
|
|
|
function start() {
|
|
new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
|
|
if (cardsQuery.addEventListener) cardsQuery.addEventListener('change', schedule);
|
|
else cardsQuery.addListener(schedule);
|
|
window.addEventListener('resize', schedule);
|
|
// Groups and pages are shown by toggling display (not a DOM change), so also
|
|
// re-measure as the user scrolls; writes only happen when a value changes.
|
|
window.addEventListener('scroll', schedule, { passive: true });
|
|
// Custom View rebuilds its header (and re-attaches drag handlers) on every render
|
|
if (typeof window.attachColDragHandlers === 'function') {
|
|
const origDrag = window.attachColDragHandlers;
|
|
window.attachColDragHandlers = function () { const r = origDrag.apply(this, arguments); enhanceColumnDrag(); return r; };
|
|
enhanceColumnDrag();
|
|
}
|
|
// Page switches show and hide pages without changing the DOM
|
|
if (typeof window.switchPage === 'function') {
|
|
const orig = window.switchPage;
|
|
window.switchPage = function () { const r = orig.apply(this, arguments); schedule(); return r; };
|
|
}
|
|
// The terminal dock opens, closes and resizes without a page resize
|
|
if (window.ResizeObserver) {
|
|
const watchDock = () => {
|
|
const dock = document.getElementById('usmt-term');
|
|
if (dock) new ResizeObserver(schedule).observe(dock); else setTimeout(watchDock, 500);
|
|
};
|
|
watchDock();
|
|
}
|
|
schedule();
|
|
}
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
|
|
else start();
|
|
})();
|