feat: task-6 header dropdown view switching (renderViewDropdown, toggleViewDropdown, closeViewDropdown, switchView)

- Add renderViewDropdown() populating #view-dropdown-menu with All Sessions,
  user views, Hidden (count), + New View, Manage Views; updates label
- Add toggleViewDropdown() toggling hidden class and aria-expanded
- Add closeViewDropdown() hiding menu and cleaning up inline inputs
- Add switchView(viewName) setting _activeView, re-rendering, persisting
  active_view via PATCH /api/state (fire-and-forget)
- Restore _activeView from server state in restoreState()
- Bind view dropdown event listeners in bindStaticEventListeners():
  trigger click, delegated menu item clicks, click-outside dismiss
- Export all 4 new functions in module.exports
- Add 5 new tests: render_view_dropdown_function_exists,
  render_view_dropdown_exported, toggle_view_dropdown_function_exists,
  switch_view_function_exists, switch_view_patches_state
This commit is contained in:
Brian Krabach
2026-04-15 17:46:01 -07:00
parent 8ed0e94f80
commit 9afa4b2347
2 changed files with 197 additions and 0 deletions
+144
View File
@@ -218,6 +218,9 @@ async function restoreState() {
try { try {
const res = await api('GET', '/api/state'); const res = await api('GET', '/api/state');
const state = await res.json(); const state = await res.json();
if (state.active_view) {
_activeView = state.active_view;
}
if (state.active_session) { if (state.active_session) {
await openSession(state.active_session, { await openSession(state.active_session, {
skipAnimation: true, skipAnimation: true,
@@ -804,6 +807,112 @@ function renderFilterBar(container, allSessions) {
container.innerHTML = html; 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 += '<button class="view-dropdown-item' + allActive + '" data-view="all"><span class="view-dropdown-shortcut">1</span> All Sessions</button>';
// — User views (shortcuts 28)
if (views.length > 0) {
html += '<div class="view-dropdown-separator"></div>';
for (var i = 0; i < views.length && i < 7; i++) {
var v = views[i];
var vActive = _activeView === v.name ? ' view-dropdown-item--active' : '';
html += '<button class="view-dropdown-item' + vActive + '" data-view="' + escapeHtml(v.name) + '"><span class="view-dropdown-shortcut">' + (i + 2) + '</span> ' + escapeHtml(v.name) + '</button>';
}
}
// — Hidden (N) (always last system view, shortcut 9)
html += '<div class="view-dropdown-separator"></div>';
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown-item--active' : '';
html += '<button class="view-dropdown-item' + hiddenActive + '" data-view="hidden"><span class="view-dropdown-shortcut">9</span> Hidden <span class="view-dropdown-badge">' + hiddenCount + '</span></button>';
// — Actions
html += '<div class="view-dropdown-separator"></div>';
html += '<button class="view-dropdown-item view-dropdown-action" data-action="new-view">+ New View</button>';
html += '<button class="view-dropdown-item view-dropdown-action" data-action="manage-views">Manage Views\u2026</button>';
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) { function renderGrid(sessions) {
var grid = $('session-grid'); var grid = $('session-grid');
var emptyState = $('empty-state'); var emptyState = $('empty-state');
@@ -2258,6 +2367,36 @@ function bindStaticEventListeners() {
}); });
on($('back-btn'), 'click', closeSession); 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'); var newSessionBtn = $('new-session-btn');
if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); }); if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); });
var sidebarNewSessionBtn = $('sidebar-new-session-btn'); var sidebarNewSessionBtn = $('sidebar-new-session-btn');
@@ -2670,6 +2809,11 @@ if (typeof module !== 'undefined' && module.exports) {
killSession, killSession,
// Filter bar // Filter bar
renderFilterBar, renderFilterBar,
// View dropdown
renderViewDropdown,
toggleViewDropdown,
closeViewDropdown,
switchView,
// Federation tiles // Federation tiles
buildStatusTileHTML, buildStatusTileHTML,
// Constants // Constants
+53
View File
@@ -3112,3 +3112,56 @@ def test_create_new_session_references_active_view() -> None:
assert "_activeView" in body, ( assert "_activeView" in body, (
"createNewSession must reference _activeView to auto-add new session to the active user view" "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"