From 2e8cf6ddee9e1209b0518e107fcffe8982bcb27a Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 31 Mar 2026 00:00:34 -0700 Subject: [PATCH] feat: add grouped view mode to renderGrid with device-group-header sections - Add renderGroupedGrid(sessions, mobile) that groups sessions by deviceName and creates

headers for each group - Rewrite renderGrid to: apply device filter in 'filtered' mode, apply sort order, call renderGroupedGrid when _gridViewMode === 'grouped', render filter bar in 'filtered' mode, bind click handlers with sourceUrl - Add _setGridViewMode(mode) test-only helper - Export renderGroupedGrid and _setGridViewMode from module.exports --- muxplex/frontend/app.js | 95 +++++++++++++++++++++++++---- muxplex/frontend/tests/test_app.mjs | 43 +++++++++++++ 2 files changed, 125 insertions(+), 13 deletions(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index d646475..fbb9c7a 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -681,39 +681,101 @@ function bindSidebarClickAway() { * Binds click and keydown handlers on each tile. * @param {object[]} sessions */ -function renderGrid(sessions) { - const grid = $('session-grid'); - const emptyState = $('empty-state'); - const visible = getVisibleSessions(sessions); +/** + * Render sessions grouped by device name. Returns HTML string. + * @param {object[]} sessions - sorted, visible sessions + * @param {boolean} mobile + * @returns {string} + */ +function renderGroupedGrid(sessions, mobile) { + // Group by deviceName + var groups = {}; + var groupOrder = []; + for (var i = 0; i < sessions.length; i++) { + var dn = sessions[i].deviceName || 'Unknown'; + if (!groups[dn]) { + groups[dn] = []; + groupOrder.push(dn); + } + groups[dn].push(sessions[i]); + } + + var html = ''; + for (var g = 0; g < groupOrder.length; g++) { + var name = groupOrder[g]; + html += '

' + escapeHtml(name) + '

'; + var groupSessions = groups[name]; + for (var j = 0; j < groupSessions.length; j++) { + html += buildTileHTML(groupSessions[j], j, mobile); + } + } + return html; +} + +function renderGrid(sessions) { + var grid = $('session-grid'); + var emptyState = $('empty-state'); + var filterBar = $('filter-bar'); + + var visible = getVisibleSessions(sessions); + + // In filtered mode, apply device filter + if (_gridViewMode === 'filtered' && _activeFilterDevice !== 'all') { + visible = visible.filter(function(s) { return s.deviceName === _activeFilterDevice; }); + } if (visible.length === 0) { if (grid) grid.innerHTML = ''; if (emptyState) emptyState.classList.remove('hidden'); + // Show filter bar even when filtered to empty (so user can switch back) + if (filterBar) { + if (_gridViewMode === 'filtered') { + renderFilterBar(filterBar, sessions); + } else { + filterBar.innerHTML = ''; + } + } return; } if (emptyState) emptyState.classList.add('hidden'); // Apply sort order from server settings - const sortOrder = _serverSettings && _serverSettings.sort_order; - const mobile = isMobile(); - let ordered; + var sortOrder = _serverSettings && _serverSettings.sort_order; + var mobile = isMobile(); + var ordered; if (sortOrder === 'alphabetical') { - ordered = visible.slice().sort((a, b) => (a.name || '').localeCompare(b.name || '')); + ordered = visible.slice().sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); }); } else { // 'recent', 'manual', and default use server-provided order; priority sort on mobile ordered = mobile ? sortByPriority(visible) : visible; } - const html = ordered.map((session, index) => buildTileHTML(session, index, mobile)).join(''); + + var html; + if (_gridViewMode === 'grouped') { + html = renderGroupedGrid(ordered, mobile); + } else { + html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join(''); + } + if (grid) grid.innerHTML = html; + // Render filter bar + if (filterBar) { + if (_gridViewMode === 'filtered') { + renderFilterBar(filterBar, sessions); + } else { + filterBar.innerHTML = ''; + } + } + // Bind interaction handlers on each tile - document.querySelectorAll('.session-tile').forEach((tile) => { - on(tile, 'click', () => openSession(tile.dataset.session)); - on(tile, 'keydown', (e) => { + document.querySelectorAll('.session-tile').forEach(function(tile) { + on(tile, 'click', function() { openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl }); }); + on(tile, 'keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') { - openSession(tile.dataset.session); + openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl }); } }); }); @@ -1791,6 +1853,11 @@ function _getGridViewMode() { return _gridViewMode; } +/** Test-only: set _gridViewMode directly. */ +function _setGridViewMode(mode) { + _gridViewMode = mode; +} + /** Test-only: get _sources. */ function _getSources() { return _sources; @@ -1839,6 +1906,7 @@ if (typeof module !== 'undefined' && module.exports) { toggleSidebar, bindSidebarClickAway, renderGrid, + renderGroupedGrid, requestNotificationPermission, handleBellTransitions, sendHeartbeat, @@ -1891,6 +1959,7 @@ if (typeof module !== 'undefined' && module.exports) { _setSources, _setServerSettings, _getGridViewMode, + _setGridViewMode, _getSources, }; } diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 0f74950..708d362 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -2503,4 +2503,47 @@ test('pollSessions sets unreachable and applies exponential backoff on network e app._setSources([]); }); +// --- renderGrid grouped mode (task-11) --- + +test('renderGrid in grouped mode produces device-group-header elements', () => { + const collectedHTML = []; + const mockGrid = { + get innerHTML() { return collectedHTML[0] || ''; }, + set innerHTML(v) { collectedHTML[0] = v; }, + }; + const mockEmpty = { style: {}, 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 = () => []; + + // Set up sessions from two different devices + const sessions = [ + { name: 'alpha', deviceName: 'Laptop', sourceUrl: 'http://local', sessionKey: 'http://local::alpha', snapshot: '' }, + { name: 'beta', deviceName: 'Server', sourceUrl: 'http://remote', sessionKey: 'http://remote::beta', snapshot: '' }, + ]; + + app._setGridViewMode('grouped'); + app.renderGrid(sessions); + + const html = mockGrid.innerHTML; + assert.ok(html.includes('device-group-header'), 'grid HTML should contain device-group-header elements'); + assert.ok(html.includes('Laptop'), 'grid HTML should contain device name "Laptop"'); + assert.ok(html.includes('Server'), 'grid HTML should contain device name "Server"'); + + // Reset state + app._setGridViewMode('flat'); + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; +}); + +test('_setGridViewMode and renderGroupedGrid are exported', () => { + assert.strictEqual(typeof app._setGridViewMode, 'function', '_setGridViewMode should be exported'); + assert.strictEqual(typeof app.renderGroupedGrid, 'function', 'renderGroupedGrid should be exported'); +}); +