Filter menus: None first, documented picks; Custom View drag preview
- 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
This commit is contained in:
1 parent
318c59e610
commit
973ff592a4
4 files changed
+149
-5
No files matched your search
+47
-3
@@ -29,7 +29,7 @@
|
|||||||
const nativeSort = {}; // table key -> { col, dir } for tables without their own sort (Workbench)
|
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 norm = s => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
||||||
const cleanLabel = t => norm(String(t).replace(/[↕▲▼▾×✕]/g, ''));
|
const cleanLabel = t => norm(String(t).replace(/[↑↓↕▲▼▾×✕]/g, '')); // sort arrows, remove x
|
||||||
|
|
||||||
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 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 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>';
|
||||||
@@ -334,7 +334,9 @@
|
|||||||
if (!passes(ad, cols, tr, key)) continue;
|
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); }
|
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);
|
// "None" (nothing missing) first so the easy devices are one click away; (Blanks) last
|
||||||
|
const values = Array.from(seen).filter(v => v !== 'None').sort(collator.compare);
|
||||||
|
if (seen.has('None')) values.unshift('None');
|
||||||
if (hasBlank) values.push(BLANK);
|
if (hasBlank) values.push(BLANK);
|
||||||
|
|
||||||
menuCtx = { table, idx };
|
menuCtx = { table, idx };
|
||||||
@@ -382,8 +384,26 @@
|
|||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
return { row, cb };
|
return { row, cb };
|
||||||
};
|
};
|
||||||
|
// Software columns: quick picks for apps that do / don't have a knowledge-base article
|
||||||
|
const isSoftware = /software/i.test(col.label);
|
||||||
|
const docs = (typeof availableDocs !== 'undefined' && Array.isArray(availableDocs)) ? availableDocs.map(d => d.toLowerCase()) : [];
|
||||||
|
const isDocumented = v => docs.includes(String(v).toLowerCase());
|
||||||
|
let pickBar = null;
|
||||||
|
if (isSoftware && values.some(v => v !== 'None' && v !== BLANK)) {
|
||||||
|
pickBar = el('div', 'xf-picks');
|
||||||
|
menu.appendChild(pickBar);
|
||||||
|
}
|
||||||
|
|
||||||
const all = mkRow('(Select All)', true, 'xf-all');
|
const all = mkRow('(Select All)', true, 'xf-all');
|
||||||
const opts = values.map(v => ({ v, ...mkRow(v, !current || current.has(v)) }));
|
const opts = values.map(v => {
|
||||||
|
const o = { v, ...mkRow(v, !current || current.has(v)) };
|
||||||
|
if (isSoftware && isDocumented(v)) {
|
||||||
|
o.row.classList.add('xf-doc');
|
||||||
|
o.row.title = 'Has a documentation article';
|
||||||
|
}
|
||||||
|
if (v === 'None') o.row.classList.add('xf-none');
|
||||||
|
return o;
|
||||||
|
});
|
||||||
if (!values.length) list.appendChild(el('div', 'xf-empty', 'No values'));
|
if (!values.length) list.appendChild(el('div', 'xf-empty', 'No values'));
|
||||||
menu.appendChild(list);
|
menu.appendChild(list);
|
||||||
|
|
||||||
@@ -403,6 +423,24 @@
|
|||||||
};
|
};
|
||||||
all.cb.onchange = () => { visibleOpts().forEach(o => { o.cb.checked = all.cb.checked; }); sync(); };
|
all.cb.onchange = () => { visibleOpts().forEach(o => { o.cb.checked = all.cb.checked; }); sync(); };
|
||||||
opts.forEach(o => { o.cb.onchange = sync; });
|
opts.forEach(o => { o.cb.onchange = sync; });
|
||||||
|
if (pickBar) {
|
||||||
|
const pick = (label, test) => {
|
||||||
|
const n = opts.filter(o => o.v !== 'None' && o.v !== BLANK && test(o.v)).length;
|
||||||
|
const b = el('button', 'xf-pick'); b.type = 'button';
|
||||||
|
b.textContent = `${label} (${n})`;
|
||||||
|
b.title = `Tick only the apps that ${label === 'Documented' ? 'have' : "don't have"} a documentation article`;
|
||||||
|
b.disabled = n === 0;
|
||||||
|
b.onclick = () => {
|
||||||
|
search.value = '';
|
||||||
|
opts.forEach(o => { o.row.hidden = false; o.cb.checked = o.v !== 'None' && o.v !== BLANK && test(o.v); });
|
||||||
|
all.row.querySelector('span').textContent = '(Select All)';
|
||||||
|
sync();
|
||||||
|
};
|
||||||
|
pickBar.appendChild(b);
|
||||||
|
};
|
||||||
|
pick('Documented', isDocumented);
|
||||||
|
pick('Undocumented', v => !isDocumented(v));
|
||||||
|
}
|
||||||
search.oninput = () => {
|
search.oninput = () => {
|
||||||
const q = search.value.trim().toLowerCase();
|
const q = search.value.trim().toLowerCase();
|
||||||
// Excel: searching shows the matching items, all ticked
|
// Excel: searching shows the matching items, all ticked
|
||||||
@@ -558,6 +596,12 @@
|
|||||||
wrap('filterCustomTab', () => afterFilterPass(document.getElementById('table-custom')));
|
wrap('filterCustomTab', () => afterFilterPass(document.getElementById('table-custom')));
|
||||||
wrap('refreshStackTable', id => afterFilterPass(document.getElementById(`table-${id}`)));
|
wrap('refreshStackTable', id => afterFilterPass(document.getElementById(`table-${id}`)));
|
||||||
wrap('resetCustomFilters', () => { delete filters.custom; filterCustomTab(); });
|
wrap('resetCustomFilters', () => { delete filters.custom; filterCustomTab(); });
|
||||||
|
// Custom View's old filters (saved in the browser by earlier versions) have no UI any
|
||||||
|
// more, so they're cleared rather than left silently hiding rows
|
||||||
|
if (typeof customFilters !== 'undefined' && customFilters && Object.keys(customFilters).length) {
|
||||||
|
customFilters = {};
|
||||||
|
if (typeof saveCustomState === 'function') saveCustomState();
|
||||||
|
}
|
||||||
// Custom View's own filter icon opens the Excel-style menu instead
|
// Custom View's own filter icon opens the Excel-style menu instead
|
||||||
window.openColFilter = (colId, idx, anchor) => openMenu(document.getElementById('table-custom'), idx, anchor);
|
window.openColFilter = (colId, idx, anchor) => openMenu(document.getElementById('table-custom'), idx, anchor);
|
||||||
|
|
||||||
|
|||||||
@@ -536,8 +536,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="copy-table-btn no-copy" onclick="resetCustomColumns()" style="white-space:nowrap;">Reset Columns</button>
|
<button class="copy-table-btn no-copy" onclick="resetCustomColumns()" style="white-space:nowrap;">Reset Columns</button>
|
||||||
<button class="copy-table-btn no-copy" onclick="shrinkCustomColumns()" style="white-space:nowrap;">Shrink Widths</button>
|
<button class="copy-table-btn no-copy" onclick="shrinkCustomColumns()" style="white-space:nowrap;">Shrink Widths</button>
|
||||||
<button class="copy-table-btn no-copy" onclick="resetCustomFilters()" style="white-space:nowrap;">Reset Filters</button>
|
|
||||||
<button class="copy-table-btn no-copy" onclick="resetCustomSort()" style="white-space:nowrap;">Reset Sort</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-wrapper" id="wrapper-custom" style="background: var(--bg-card); border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); overflow: hidden;">
|
<div class="table-wrapper" id="wrapper-custom" style="background: var(--bg-card); border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); overflow: hidden;">
|
||||||
|
|||||||
@@ -344,3 +344,38 @@ table.xf-table thead th:has(> .xf-btn):not(.sortable-th) { position: relative; p
|
|||||||
.xf-addfield[hidden] { display: none; }
|
.xf-addfield[hidden] { display: none; }
|
||||||
.xf-clear-all { white-space: nowrap; color: #f1c40f !important; border-color: rgba(241,196,15,0.6) !important; }
|
.xf-clear-all { white-space: nowrap; color: #f1c40f !important; border-color: rgba(241,196,15,0.6) !important; }
|
||||||
.xf-clear-all svg { width: 14px; height: 14px; flex: none; }
|
.xf-clear-all svg { width: 14px; height: 14px; flex: none; }
|
||||||
|
|
||||||
|
/* Software menus: documented / undocumented quick picks, doc marker, "None" pinned first */
|
||||||
|
.xf-picks { display: flex; gap: 6px; padding: 0 12px 6px; }
|
||||||
|
.xf-pick {
|
||||||
|
flex: 1; padding: 5px 6px; cursor: pointer; font: inherit; font-size: 0.82em; white-space: nowrap;
|
||||||
|
background: transparent; color: #3498db; border: 1px solid rgba(52,152,219,0.55); border-radius: 5px;
|
||||||
|
}
|
||||||
|
.xf-pick:hover:not(:disabled) { background: rgba(52,152,219,0.12); }
|
||||||
|
.xf-pick:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.xf-opt.xf-doc span::after {
|
||||||
|
content: ''; display: inline-block; width: 9px; height: 11px; margin-left: 6px; vertical-align: -1px;
|
||||||
|
border: 1.5px solid #3498db; border-radius: 1.5px; box-sizing: border-box;
|
||||||
|
background: linear-gradient(#3498db, #3498db) 2px 3px / 3px 1.5px no-repeat,
|
||||||
|
linear-gradient(#3498db, #3498db) 2px 6px / 3px 1.5px no-repeat;
|
||||||
|
}
|
||||||
|
.xf-opt.xf-none { font-style: italic; border-bottom: 1px dashed var(--border-light); }
|
||||||
|
|
||||||
|
/* Custom View column drag preview (layout.js) */
|
||||||
|
.col-drag-ghost {
|
||||||
|
position: fixed; top: -1000px; left: -1000px; z-index: 9999; pointer-events: none;
|
||||||
|
background: var(--bg-card); color: var(--text-main); border: 2px solid #3498db; border-radius: 6px;
|
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.45); overflow: hidden; font-size: 12px;
|
||||||
|
}
|
||||||
|
.col-drag-ghost-head { padding: 7px 10px; font-weight: 700; color: #fff; background: var(--table-header-bg); }
|
||||||
|
.col-drag-ghost-cell {
|
||||||
|
padding: 5px 10px; border-top: 1px solid var(--border-light);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
#table-custom .col-moving { opacity: 0.35; }
|
||||||
|
.col-drop-marker {
|
||||||
|
position: absolute; top: 0; width: 4px; z-index: 30; pointer-events: none;
|
||||||
|
background: #3498db; border-radius: 2px; box-shadow: 0 0 8px rgba(52,152,219,0.8);
|
||||||
|
}
|
||||||
|
/* the single insertion line replaces the per-header highlight */
|
||||||
|
#table-custom .custom-th.drag-over { box-shadow: none !important; }
|
||||||
@@ -142,6 +142,67 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
let queued = false;
|
||||||
function run() {
|
function run() {
|
||||||
queued = false;
|
queued = false;
|
||||||
@@ -164,6 +225,12 @@
|
|||||||
// Groups and pages are shown by toggling display (not a DOM change), so also
|
// 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.
|
// re-measure as the user scrolls; writes only happen when a value changes.
|
||||||
window.addEventListener('scroll', schedule, { passive: true });
|
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
|
// Page switches show and hide pages without changing the DOM
|
||||||
if (typeof window.switchPage === 'function') {
|
if (typeof window.switchPage === 'function') {
|
||||||
const orig = window.switchPage;
|
const orig = window.switchPage;
|
||||||
|
|||||||
Reference in new issue
Block a user