diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index e5aa2d3..769c26c 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -218,6 +218,9 @@ async function restoreState() { try { const res = await api('GET', '/api/state'); const state = await res.json(); + if (state.active_view) { + _activeView = state.active_view; + } if (state.active_session) { await openSession(state.active_session, { skipAnimation: true, @@ -804,6 +807,112 @@ function renderFilterBar(container, allSessions) { container.innerHTML = html; } +// --------------------------------------------------------------------------- +// View dropdown — render, open/close, view switching +// --------------------------------------------------------------------------- + +/** + * Populate #view-dropdown-menu with the full view list and update the label. + * Called on open and after a view switch. + */ +function renderViewDropdown() { + var menu = $('view-dropdown-menu'); + if (!menu) return; + + var views = (_serverSettings && _serverSettings.views) || []; + var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; + var hiddenCount = hidden.length; + + var html = ''; + + // — All Sessions (always first, shortcut 1) + var allActive = _activeView === 'all' ? ' view-dropdown-item--active' : ''; + html += ''; + + // — User views (shortcuts 2–8) + if (views.length > 0) { + html += '
'; + for (var i = 0; i < views.length && i < 7; i++) { + var v = views[i]; + var vActive = _activeView === v.name ? ' view-dropdown-item--active' : ''; + html += ''; + } + } + + // — Hidden (N) (always last system view, shortcut 9) + html += '
'; + var hiddenActive = _activeView === 'hidden' ? ' view-dropdown-item--active' : ''; + html += ''; + + // — Actions + html += '
'; + html += ''; + html += ''; + + menu.innerHTML = html; + + // Update the label + var label = $('view-dropdown-label'); + if (label) { + if (_activeView === 'all') { + label.textContent = 'All Sessions'; + } else if (_activeView === 'hidden') { + label.textContent = 'Hidden'; + } else { + label.textContent = escapeHtml(_activeView); + } + } +} + +/** + * Toggle the view dropdown open/closed. + * Calls renderViewDropdown() when opening to ensure fresh content. + */ +function toggleViewDropdown() { + var menu = $('view-dropdown-menu'); + var trigger = $('view-dropdown-trigger'); + if (!menu) return; + + var isOpen = !menu.classList.contains('hidden'); + if (isOpen) { + closeViewDropdown(); + } else { + menu.classList.remove('hidden'); + if (trigger) trigger.setAttribute('aria-expanded', 'true'); + renderViewDropdown(); + } +} + +/** + * Close the view dropdown. Removes inline new-view input if present. + */ +function closeViewDropdown() { + var menu = $('view-dropdown-menu'); + var trigger = $('view-dropdown-trigger'); + if (menu) { + menu.classList.add('hidden'); + // Remove any inline new-view input + var newViewInput = menu.querySelector('.view-dropdown-new-input'); + if (newViewInput) newViewInput.remove(); + } + if (trigger) trigger.setAttribute('aria-expanded', 'false'); +} + +/** + * Switch to a named view. Updates _activeView, re-renders the grid and sidebar, + * updates the dropdown label, and persists the change via PATCH /api/state. + * @param {string} viewName - 'all', 'hidden', or a user view name. + */ +function switchView(viewName) { + _activeView = viewName; + closeViewDropdown(); + renderGrid(_currentSessions || []); + renderSidebar(_currentSessions || [], _viewingSession); + renderViewDropdown(); + // Persist active view — fire and forget + api('PATCH', '/api/state', { active_view: viewName }).catch(function() {}); +} + function renderGrid(sessions) { var grid = $('session-grid'); var emptyState = $('empty-state'); @@ -2258,6 +2367,36 @@ function bindStaticEventListeners() { }); on($('back-btn'), 'click', closeSession); + + // View dropdown — trigger opens/closes, delegated item clicks switch view + var viewDropdownTrigger = $('view-dropdown-trigger'); + if (viewDropdownTrigger) on(viewDropdownTrigger, 'click', toggleViewDropdown); + + var viewDropdownMenu = $('view-dropdown-menu'); + if (viewDropdownMenu) { + viewDropdownMenu.addEventListener('click', function(e) { + var item = e.target.closest('[data-view]'); + if (item) { + switchView(item.dataset.view); + return; + } + var action = e.target.closest('[data-action]'); + if (action) { + // action handlers (new-view, manage-views) — placeholder for future tasks + closeViewDropdown(); + } + }); + } + + // Click-outside closes the dropdown + document.addEventListener('click', function(e) { + var dropdown = $('view-dropdown-menu'); + if (!dropdown || dropdown.classList.contains('hidden')) return; + var trigger = $('view-dropdown-trigger'); + if (trigger && trigger.contains(e.target)) return; + if (!dropdown.contains(e.target)) closeViewDropdown(); + }); + var newSessionBtn = $('new-session-btn'); if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); }); var sidebarNewSessionBtn = $('sidebar-new-session-btn'); @@ -2670,6 +2809,11 @@ if (typeof module !== 'undefined' && module.exports) { killSession, // Filter bar renderFilterBar, + // View dropdown + renderViewDropdown, + toggleViewDropdown, + closeViewDropdown, + switchView, // Federation tiles buildStatusTileHTML, // Constants diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index 507e966..dbb6fd1 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -3112,3 +3112,56 @@ def test_create_new_session_references_active_view() -> None: assert "_activeView" in body, ( "createNewSession must reference _activeView to auto-add new session to the active user view" ) + + +# --------------------------------------------------------------------------- +# Header Dropdown — JS Render + Open/Close + View Switching (task-6) +# --------------------------------------------------------------------------- + + +def test_render_view_dropdown_function_exists() -> None: + """renderViewDropdown function must exist in app.js.""" + assert "function renderViewDropdown" in _JS, ( + "renderViewDropdown must be defined in app.js" + ) + + +def test_render_view_dropdown_exported() -> None: + """module.exports must export renderViewDropdown.""" + match = re.search( + r"module\.exports\s*=\s*\{(.*?)\};", + _JS, + re.DOTALL, + ) + assert match, "module.exports block not found" + exports = match.group(1) + assert "renderViewDropdown" in exports, ( + "module.exports must export renderViewDropdown" + ) + + +def test_toggle_view_dropdown_function_exists() -> None: + """toggleViewDropdown function must exist in app.js.""" + assert "function toggleViewDropdown" in _JS, ( + "toggleViewDropdown must be defined in app.js" + ) + + +def test_switch_view_function_exists() -> None: + """switchView function must exist in app.js.""" + assert "function switchView" in _JS, ( + "switchView must be defined in app.js" + ) + + +def test_switch_view_patches_state() -> None: + """switchView must PATCH /api/state to persist active_view.""" + match = re.search( + r"function switchView\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )", + _JS, + re.DOTALL, + ) + assert match, "switchView function not found" + body = match.group(1) + assert "PATCH" in body, "switchView must use PATCH method" + assert "/api/state" in body, "switchView must PATCH /api/state"