From 43c559f812895c1689060523ac834b10497ab10d Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 17 May 2026 16:09:53 -0700 Subject: [PATCH] feat(ui): suppress empty device blocks in grouped grid view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In grouped-by-device mode, devices whose sessions are all filtered out of the current view (hidden, not in the active user view, or status-only tiles) were still rendering a device-group header with an empty body — visual clutter that made the dashboard feel broken. Change: renderGroupedGrid() now skips a device entirely when its session list is empty after partitioning the already-filtered visible session set. The guard (devSessions.length === 0 → continue) is placed before the

write so no HTML is emitted for the empty device. The empty-state UI path (visible.length === 0 early-return in renderGrid) is unaffected — it runs before renderGroupedGrid is ever called, so the "all sessions hidden" case still shows the empty state. Filtering source: renderGrid() already calls getVisibleSessions() → filterVisible(_currentSessions, _serverSettings, _activeView) before passing the ordered list to renderGroupedGrid(), so status tiles and hidden sessions are never in the partition input. Tests added (test_app.mjs — v0.6.3 section): - grouped view skips device header when device has only hidden sessions - grouped view still shows device header when ≥1 session is visible - empty-state still appears when every device has zero visible sessions --- muxplex/frontend/app.js | 6 +- muxplex/frontend/tests/test_app.mjs | 129 ++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index ca01145..82ee37f 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -1013,8 +1013,12 @@ function renderGroupedGrid(sessions, mobile) { var html = ''; for (var g = 0; g < groupOrder.length; g++) { var name = groupOrder[g]; - html += '

' + escapeHtml(name) + '

'; var groupSessions = groups[name]; + // Skip device entirely when it has no visible sessions to render. + // This prevents empty device headers from appearing in the grouped grid + // (e.g. when all of a device's sessions are hidden in the current view). + if (groupSessions.length === 0) continue; + html += '

' + escapeHtml(name) + '

'; for (var j = 0; j < groupSessions.length; j++) { html += buildTileHTML(groupSessions[j], j, mobile); } diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 548a95a..4bc5284 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -5494,3 +5494,132 @@ test('Phase 5: isHidden() helper (not inline check) drives the dim class', () => assert.strictEqual(app.isHidden('dev1:sess-a', null), false, 'isHidden must return false when settings is null'); }); + +// ─── v0.6.3 empty-device suppression ──────────────────────────────────────── + +test('v0.6.3: grouped view skips device header when device has only hidden sessions', () => { + // 3 devices: DeviceA and DeviceC have visible sessions; DeviceB has only a + // hidden session. In the "all" view DeviceB's session is filtered out by + // filterVisible(), so renderGroupedGrid() receives an ordered list with no + // DeviceB entries — meaning no DeviceB header should appear in the HTML. + const sessions = [ + { name: 'alpha', deviceName: 'DeviceA', snapshot: '', sessionKey: 'DeviceA:alpha' }, + { name: 'beta', deviceName: 'DeviceB', snapshot: '', sessionKey: 'DeviceB:beta' }, + { name: 'gamma', deviceName: 'DeviceC', snapshot: '', sessionKey: 'DeviceC:gamma' }, + ]; + app._setServerSettings({ hidden_sessions: ['DeviceB:beta'] }); + app._setGridViewMode('grouped'); + app._setActiveView('all'); + + let capturedHTML = ''; + const mockGrid = { + get innerHTML() { return capturedHTML; }, + set innerHTML(v) { capturedHTML = v; }, + }; + const mockEmpty = { classList: { add: () => {}, remove: () => {} } }; + const origGetById = globalThis.document.getElementById; + const origQSA = globalThis.document.querySelectorAll; + globalThis.document.getElementById = (id) => { + if (id === 'session-grid') return mockGrid; + if (id === 'empty-state') return mockEmpty; + return null; + }; + globalThis.document.querySelectorAll = () => []; + + app.renderGrid(sessions); + + assert.ok(!capturedHTML.includes('DeviceB'), + 'DeviceB header must NOT appear when all its sessions are hidden'); + assert.ok(capturedHTML.includes('DeviceA'), + 'DeviceA header must appear (has at least one visible session)'); + assert.ok(capturedHTML.includes('DeviceC'), + 'DeviceC header must appear (has at least one visible session)'); + + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; + app._setGridViewMode('flat'); + app._setServerSettings(null); + app._setActiveView('all'); +}); + +test('v0.6.3: grouped view still shows device header when device has at least one visible session', () => { + // Sanity-check the positive case: DeviceA has two sessions, one of which is + // hidden. Because one session (alpha) remains visible, DeviceA's block must + // appear in the rendered HTML. + const sessions = [ + { name: 'alpha', deviceName: 'DeviceA', snapshot: '', sessionKey: 'DeviceA:alpha' }, + { name: 'beta', deviceName: 'DeviceA', snapshot: '', sessionKey: 'DeviceA:beta' }, + { name: 'gamma', deviceName: 'DeviceB', snapshot: '', sessionKey: 'DeviceB:gamma' }, + ]; + app._setServerSettings({ hidden_sessions: ['DeviceA:beta'] }); // only beta hidden + app._setGridViewMode('grouped'); + app._setActiveView('all'); + + let capturedHTML = ''; + const mockGrid = { + get innerHTML() { return capturedHTML; }, + set innerHTML(v) { capturedHTML = v; }, + }; + const mockEmpty = { classList: { add: () => {}, remove: () => {} } }; + const origGetById = globalThis.document.getElementById; + const origQSA = globalThis.document.querySelectorAll; + globalThis.document.getElementById = (id) => { + if (id === 'session-grid') return mockGrid; + if (id === 'empty-state') return mockEmpty; + return null; + }; + globalThis.document.querySelectorAll = () => []; + + app.renderGrid(sessions); + + assert.ok(capturedHTML.includes('DeviceA'), + 'DeviceA header must appear — alpha is still visible even though beta is hidden'); + assert.ok(capturedHTML.includes('DeviceB'), + 'DeviceB header must appear — gamma is visible'); + + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; + app._setGridViewMode('flat'); + app._setServerSettings(null); + app._setActiveView('all'); +}); + +test('v0.6.3: empty-state still appears when every device has zero visible sessions', () => { + // When ALL sessions across ALL devices are hidden, visible.length === 0. + // renderGrid() must still reach its early-return branch and show empty-state, + // NOT fall through to grouped rendering and produce a blank grid. + const sessions = [ + { name: 'alpha', deviceName: 'DeviceA', snapshot: '', sessionKey: 'DeviceA:alpha' }, + { name: 'beta', deviceName: 'DeviceB', snapshot: '', sessionKey: 'DeviceB:beta' }, + ]; + app._setServerSettings({ hidden_sessions: ['DeviceA:alpha', 'DeviceB:beta'] }); + app._setGridViewMode('grouped'); + app._setActiveView('all'); + + const removedFromEmpty = []; + const mockGrid = { innerHTML: '' }; + const mockEmpty = { + classList: { + add: () => {}, + remove: (c) => removedFromEmpty.push(c), + }, + }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'session-grid') return mockGrid; + if (id === 'empty-state') return mockEmpty; + return null; + }; + + app.renderGrid(sessions); + + assert.ok(removedFromEmpty.includes('hidden'), + 'empty-state must have its "hidden" class removed (i.e. become visible) when all sessions are hidden'); + assert.ok(!mockGrid.innerHTML.includes('device-group-header'), + 'no device-group-header elements must appear when all sessions are hidden'); + + globalThis.document.getElementById = origGetById; + app._setGridViewMode('flat'); + app._setServerSettings(null); + app._setActiveView('all'); +});