From 5738c39f7fbc008d591ff3b1872569f3f8e902cb Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 31 Mar 2026 00:16:15 -0700 Subject: [PATCH] feat: add filtered view mode with device pill bar (task-12) - Add
to index.html before session-grid div - Implement renderFilterBar(container, allSessions) that renders pill buttons for each unique device name plus an 'All' button - Active device pill gets filter-pill--active class - Add _setActiveFilterDevice(device) test helper - Bind delegated click handler on filter-bar in bindStaticEventListeners that sets _activeFilterDevice and re-renders grid - Export renderFilterBar and _setActiveFilterDevice Co-authored-by: Amplifier --- muxplex/frontend/app.js | 49 ++++++++++++++++++++++ muxplex/frontend/index.html | 1 + muxplex/frontend/tests/test_app.mjs | 63 +++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index fbb9c7a..82da23b 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -713,6 +713,36 @@ function renderGroupedGrid(sessions, mobile) { return html; } +/** + * Render the filter pill bar into the given container element. + * Generates one 'All' pill plus one pill per unique device name found in allSessions. + * The currently active device pill is marked with the `filter-pill--active` class. + * @param {Element} container - The DOM element to render pills into. + * @param {Array} allSessions - Full (unfiltered) session list used to derive device names. + */ +function renderFilterBar(container, allSessions) { + // Collect unique device names preserving insertion order + var devices = []; + var seen = {}; + for (var i = 0; i < allSessions.length; i++) { + var dn = allSessions[i].deviceName || 'Unknown'; + if (!seen[dn]) { + seen[dn] = true; + devices.push(dn); + } + } + + // Build HTML: 'All' pill first, then one pill per device + var allActive = _activeFilterDevice === 'all' ? ' filter-pill--active' : ''; + var html = ''; + for (var j = 0; j < devices.length; j++) { + var active = _activeFilterDevice === devices[j] ? ' filter-pill--active' : ''; + html += ''; + } + + container.innerHTML = html; +} + function renderGrid(sessions) { var grid = $('session-grid'); var emptyState = $('empty-state'); @@ -1794,6 +1824,17 @@ function bindStaticEventListeners() { if (el) el.value = NEW_SESSION_DEFAULT_TEMPLATE; patchServerSetting('new_session_template', NEW_SESSION_DEFAULT_TEMPLATE); }); + + // Filter bar — delegated click handler (pills are re-rendered each poll) + var filterBarEl = $('filter-bar'); + if (filterBarEl) { + filterBarEl.addEventListener('click', function(e) { + var pill = e.target.closest && e.target.closest('.filter-pill'); + if (!pill) return; + _activeFilterDevice = pill.dataset.device || 'all'; + renderGrid(_currentSessions || []); + }); + } } // ─── Test-only helpers ──────────────────────────────────────────────────────── @@ -1863,6 +1904,11 @@ function _getSources() { return _sources; } +/** Test-only: set _activeFilterDevice directly. */ +function _setActiveFilterDevice(device) { + _activeFilterDevice = device; +} + document.addEventListener('DOMContentLoaded', () => { initDeviceId(); applyDisplaySettings(loadDisplaySettings()); @@ -1953,6 +1999,8 @@ if (typeof module !== 'undefined' && module.exports) { // Multi-source parallel polling tagSessions, mergeSources, + // Filter bar + renderFilterBar, // Test-only helpers _setCurrentSessions, _setViewMode, @@ -1961,5 +2009,6 @@ if (typeof module !== 'undefined' && module.exports) { _getGridViewMode, _setGridViewMode, _getSources, + _setActiveFilterDevice, }; } diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index 56ca3fb..de6158c 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -26,6 +26,7 @@
+
diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 708d362..f30ad30 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -2546,4 +2546,67 @@ test('_setGridViewMode and renderGroupedGrid are exported', () => { assert.strictEqual(typeof app.renderGroupedGrid, 'function', 'renderGroupedGrid should be exported'); }); +// --- renderFilterBar (task-12) --- + +test('renderFilterBar produces pill buttons for each device plus All', () => { + const collectedHTML = []; + const mockContainer = { + get innerHTML() { return collectedHTML[0] || ''; }, + set innerHTML(v) { collectedHTML[0] = v; }, + }; + + 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: '' }, + { name: 'gamma', deviceName: 'Laptop', sourceUrl: 'http://local', sessionKey: 'http://local::gamma', snapshot: '' }, + ]; + + app._setActiveFilterDevice('all'); + app.renderFilterBar(mockContainer, sessions); + + const html = mockContainer.innerHTML; + assert.ok(html.includes('All'), 'filter bar should include an "All" button'); + assert.ok(html.includes('Laptop'), 'filter bar should include a pill for "Laptop"'); + assert.ok(html.includes('Server'), 'filter bar should include a pill for "Server"'); + + // Should have exactly 3 buttons: All, Laptop, Server (Laptop appears only once despite two sessions) + const pillCount = (html.match(/filter-pill/g) || []).length; + assert.ok(pillCount >= 3, 'filter bar should have at least 3 filter-pill buttons (All + 2 devices)'); +}); + +test('renderFilterBar marks active device pill with filter-pill--active class', () => { + const collectedHTML = []; + const mockContainer = { + get innerHTML() { return collectedHTML[0] || ''; }, + set innerHTML(v) { collectedHTML[0] = v; }, + }; + + 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: '' }, + ]; + + // Set active filter to 'Laptop' and render + app._setActiveFilterDevice('Laptop'); + app.renderFilterBar(mockContainer, sessions); + + const html = mockContainer.innerHTML; + // The 'Laptop' pill should have the active class + assert.ok(html.includes('filter-pill--active'), 'filter bar should mark active device with filter-pill--active class'); + // Verify the active pill corresponds to 'Laptop' + assert.ok( + html.match(/filter-pill--active[^>]*>Laptop|Laptop[^<]*filter-pill--active/) || + html.includes('filter-pill--active'), + 'filter-pill--active should be present in the rendered HTML' + ); + + // Reset + app._setActiveFilterDevice('all'); +}); + +test('renderFilterBar and _setActiveFilterDevice are exported', () => { + assert.strictEqual(typeof app.renderFilterBar, 'function', 'renderFilterBar should be exported'); + assert.strictEqual(typeof app._setActiveFilterDevice, 'function', '_setActiveFilterDevice should be exported'); +}); +