diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index f2f3fc3..3e88b88 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -898,6 +898,87 @@ function closeViewDropdown() { if (trigger) trigger.setAttribute('aria-expanded', 'false'); } +/** + * Show an inline text input inside the view dropdown for creating a new view. + * Replaces the '+ New View' button with a text input inside the dropdown menu. + * - Removes any existing input and re-focuses it if already present. + * - On Enter: validates name (not empty, not reserved, not duplicate), + * then PATCHes /api/settings with the new view appended to views, + * updates _serverSettings.views on success, and calls switchView(name). + * - On Escape: closes the dropdown. + * - On blur: closes the dropdown after 150ms if input is no longer focused. + */ +function showNewViewInput() { + var menu = $('view-dropdown-menu'); + if (!menu) return; + + // Re-focus existing input instead of creating a duplicate + var existing = menu.querySelector('.view-dropdown__new-input'); + if (existing) { + existing.focus(); + return; + } + + // Find the '+ New View' button to replace + var newViewBtn = menu.querySelector('[data-action="new-view"]'); + if (!newViewBtn) return; + + // Create the inline text input + var input = document.createElement('input'); + input.type = 'text'; + input.className = 'view-dropdown__new-input'; + input.placeholder = 'View name'; + input.maxLength = 30; + input.setAttribute('aria-label', 'New view name'); + + // Replace the '+ New View' button with the input + newViewBtn.parentNode.replaceChild(input, newViewBtn); + input.focus(); + + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + var name = input.value.trim(); + + // Validate: not empty + if (!name) return; + + // Validate: not reserved + if (name === 'all' || name === 'hidden') { + showToast('Cannot use reserved name \'' + name + '\''); + return; + } + + // Validate: not duplicate + var views = (_serverSettings && _serverSettings.views) || []; + if (views.find(function(v) { return v.name === name; })) { + showToast('View \'' + name + '\' already exists'); + return; + } + + // Create view and PATCH /api/settings + var updatedViews = views.concat([{ name: name, sessions: [] }]); + api('PATCH', '/api/settings', { views: updatedViews }) + .then(function() { + if (_serverSettings) _serverSettings.views = updatedViews; + switchView(name); + }) + .catch(function() { + showToast('Failed to create view'); + }); + } else if (e.key === 'Escape') { + closeViewDropdown(); + } + }); + + input.addEventListener('blur', function() { + setTimeout(function() { + if (document.activeElement !== input) { + closeViewDropdown(); + } + }, 150); + }); +} + /** * 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. @@ -2439,8 +2520,11 @@ function bindStaticEventListeners() { } var action = e.target.closest('[data-action]'); if (action) { - // action handlers (new-view, manage-views) — placeholder for future tasks - closeViewDropdown(); + if (action.dataset.action === 'new-view') { + showNewViewInput(); + } else { + closeViewDropdown(); + } } }); } @@ -2870,6 +2954,7 @@ if (typeof module !== 'undefined' && module.exports) { renderViewDropdown, toggleViewDropdown, closeViewDropdown, + showNewViewInput, switchView, // Federation tiles buildStatusTileHTML, diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index 85f8727..d8ee4aa 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -3229,3 +3229,35 @@ def test_backtick_only_on_grid_not_fullscreen() -> None: assert "'grid'" in body, ( "handleGlobalKeydown must check _viewMode === 'grid' before handling backtick/number shortcuts" ) + + +# --------------------------------------------------------------------------- +# New View inline creation flow (task-8) +# --------------------------------------------------------------------------- + + +def test_show_new_view_input_function_exists() -> None: + """app.js defines showNewViewInput for inline view creation.""" + assert "function showNewViewInput" in _JS, ( + "showNewViewInput must be defined in app.js" + ) + + +def test_show_new_view_input_patches_settings() -> None: + """showNewViewInput handler PATCHes /api/settings with views on Enter.""" + match = re.search( + r"function showNewViewInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showNewViewInput function not found" + body = match.group(1) + assert "PATCH" in body, ( + "showNewViewInput must PATCH /api/settings to create the view" + ) + assert "/api/settings" in body, ( + "showNewViewInput must PATCH /api/settings with the updated views" + ) + assert "views" in body, ( + "showNewViewInput must include 'views' in the PATCH body" + )