fix: UX refinements from live testing — sidebar new view, session counts, empty view flow, header add button, tile layout
Issue 1: Add '+ New View' to sidebar dropdown - Add '+ New View' action button (data-action='new-view') to renderSidebarViewDropdown() - Add showSidebarNewViewInput() function targeting #sidebar-view-dropdown-menu - Wire sidebar dropdown click handler to call showSidebarNewViewInput() - Also wire 'Manage Views' action in sidebar dropdown Issue 2: Remove keyboard shortcut numbers, add session counts - Remove <span class='view-dropdown__shortcut'> elements from both render functions - Show count of non-hidden sessions next to 'All Sessions' - Show each user view's session count (view.sessions.length) in parentheses - Keep keyboard shortcuts functional (backtick, 1-9) — just remove visual display Issue 3: Empty new view opens Add Sessions panel - Call openAddSessionsPanel() after switchView() in showNewViewInput() Enter handler - Same in showSidebarNewViewInput() — user immediately sees panel to populate new view Issue 4: Add '+ New View' option to tile flyout submenu - Add separator + '+ New View' button at bottom of _openFlyoutSubmenu() view list - Click handler: close flyout, prompt for name, validate, create view with session, switchView - Remove early exit when views.length === 0 (show New View option even with no views) Issue 5: Fix openAddSessionsPanel entry point + move to header - Remove broken add-sessions-tile with onclick='window.MuxplexApp.openAddSessionsPanel()' - Add <button id='add-sessions-btn'> to header-actions in index.html (hidden by default) - Add updateAddSessionsButton() to show/hide button based on active view - Wire button click to openAddSessionsPanel() in bindStaticEventListeners() - Call updateAddSessionsButton() from switchView() and DOMContentLoaded Issue 6: Fix device badge overlapping flyout icon - Move tile-options-btn inside tile-header as last flex item (was absolute sibling of article) - Move device badge out of tile-meta, now a direct flex sibling between tile-name and tile-meta - Remove tile-meta-sep separator (badge no longer in meta) - Add gap: 4px to .tile-header CSS - Remove position:absolute from .tile-options-btn CSS — flex-shrink:0 instead Tests: Add 17 new static analysis tests in test_frontend_js.py for all 6 issues. Update test_app.mjs tile tests to match new tile-header layout. All 318 frontend tests + 224 backend tests pass.
This commit is contained in:
+209
-31
@@ -539,10 +539,11 @@ function buildTileHTML(session, index, mobile) {
|
||||
`<article class="${classes}" data-session="${escapedName}" data-session-key="${escapeHtml(session.sessionKey || name)}"${remoteIdAttr} tabindex="0" role="listitem" aria-label="${escapedName}">` +
|
||||
`<div class="tile-header">` +
|
||||
`<span class="tile-name">${escapeHtml(name)}</span>` +
|
||||
`<span class="tile-meta">${badgeHtml}${badgeHtml ? `<span class="tile-meta-sep">\xb7</span>` : ''}<span class="tile-time">${escapeHtml(timeStr)}</span></span>` +
|
||||
`${badgeHtml}` +
|
||||
`<span class="tile-meta">${escapeHtml(timeStr)}</span>` +
|
||||
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">⋮</button>` +
|
||||
`</div>` +
|
||||
`<div class="tile-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
||||
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">⋮</button>` +
|
||||
`</article>`
|
||||
);
|
||||
}
|
||||
@@ -886,24 +887,29 @@ function renderViewDropdown() {
|
||||
|
||||
var html = '';
|
||||
|
||||
// — All Sessions (always first, shortcut 1)
|
||||
// — All Sessions (always first) — show count of non-hidden sessions
|
||||
var allHiddenSessions = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
||||
var allCount = (_currentSessions || []).filter(function(s) {
|
||||
if (s.status) return false;
|
||||
return allHiddenSessions.indexOf(s.sessionKey || s.name) === -1 && allHiddenSessions.indexOf(s.name) === -1;
|
||||
}).length;
|
||||
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all"><span class="view-dropdown__shortcut">1</span> All Sessions</button>';
|
||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + allCount + '</span></button>';
|
||||
|
||||
// — User views (shortcuts 2–8)
|
||||
// — User views
|
||||
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 + '" role="menuitem" data-view="' + escapeHtml(v.name) + '"><span class="view-dropdown__shortcut">' + (i + 2) + '</span> ' + escapeHtml(v.name) + '</button>';
|
||||
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + (v.sessions || []).length + '</span></button>';
|
||||
}
|
||||
}
|
||||
|
||||
// — Hidden (N) (always last system view, shortcut 9)
|
||||
// — Hidden (N) (always last system view)
|
||||
html += '<div class="view-dropdown__separator"></div>';
|
||||
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden"><span class="view-dropdown__shortcut">9</span> Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
|
||||
html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden">Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
|
||||
|
||||
// — Actions
|
||||
html += '<div class="view-dropdown__separator"></div>';
|
||||
@@ -973,9 +979,14 @@ function renderSidebarViewDropdown() {
|
||||
|
||||
var html = '';
|
||||
|
||||
// — All Sessions (always first)
|
||||
// — All Sessions (always first) — show count of non-hidden sessions
|
||||
var sbHiddenSessions = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
||||
var sbAllCount = (_currentSessions || []).filter(function(s) {
|
||||
if (s.status) return false;
|
||||
return sbHiddenSessions.indexOf(s.sessionKey || s.name) === -1 && sbHiddenSessions.indexOf(s.name) === -1;
|
||||
}).length;
|
||||
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all"><span class="view-dropdown__shortcut">1</span> All Sessions</button>';
|
||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + sbAllCount + '</span></button>';
|
||||
|
||||
// — User views
|
||||
if (views.length > 0) {
|
||||
@@ -983,14 +994,19 @@ function renderSidebarViewDropdown() {
|
||||
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 + '" role="menuitem" data-view="' + escapeHtml(v.name) + '"><span class="view-dropdown__shortcut">' + (i + 2) + '</span> ' + escapeHtml(v.name) + '</button>';
|
||||
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + (v.sessions || []).length + '</span></button>';
|
||||
}
|
||||
}
|
||||
|
||||
// — Hidden (N) (always last system view)
|
||||
html += '<div class="view-dropdown__separator"></div>';
|
||||
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden"><span class="view-dropdown__shortcut">9</span> Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
|
||||
html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden">Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
|
||||
|
||||
// — Actions: new view + manage views
|
||||
html += '<div class="view-dropdown__separator"></div>';
|
||||
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="new-view">+ New View</button>';
|
||||
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="manage-views">Manage Views\u2026</button>';
|
||||
|
||||
menu.innerHTML = html;
|
||||
}
|
||||
@@ -1084,6 +1100,7 @@ function showNewViewInput() {
|
||||
.then(function() {
|
||||
if (_serverSettings) _serverSettings.views = updatedViews;
|
||||
switchView(name);
|
||||
openAddSessionsPanel();
|
||||
})
|
||||
.catch(function() {
|
||||
showToast('Failed to create view');
|
||||
@@ -1102,6 +1119,91 @@ function showNewViewInput() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an inline text input inside the SIDEBAR view dropdown for creating a new view.
|
||||
* Targets #sidebar-view-dropdown-menu instead of #view-dropdown-menu.
|
||||
* - On Enter: validates, PATCHes /api/settings, calls switchView + openAddSessionsPanel.
|
||||
* - On Escape / blur: closes the sidebar dropdown.
|
||||
*/
|
||||
function showSidebarNewViewInput() {
|
||||
var menu = $('sidebar-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();
|
||||
|
||||
function closeSidebarDropdown() {
|
||||
menu.classList.add('hidden');
|
||||
var trigger = $('sidebar-view-dropdown-trigger');
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
var name = input.value.trim();
|
||||
|
||||
// Validate: not empty
|
||||
if (!name) return;
|
||||
|
||||
// Validate: not reserved (case-insensitive)
|
||||
if (name.toLowerCase() === 'all' || name.toLowerCase() === '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;
|
||||
closeSidebarDropdown();
|
||||
switchView(name);
|
||||
openAddSessionsPanel();
|
||||
})
|
||||
.catch(function() {
|
||||
showToast('Failed to create view');
|
||||
});
|
||||
} else if (e.key === 'Escape') {
|
||||
closeSidebarDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', function() {
|
||||
setTimeout(function() {
|
||||
if (document.activeElement !== input) {
|
||||
closeSidebarDropdown();
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save updated views array via PATCH /api/settings, update _serverSettings,
|
||||
* re-render the views settings tab, and re-render the view dropdown.
|
||||
@@ -1372,6 +1474,8 @@ function switchView(viewName) {
|
||||
sidebarLabel.textContent = viewName;
|
||||
}
|
||||
}
|
||||
// Show/hide the header Add Sessions button
|
||||
updateAddSessionsButton();
|
||||
// Persist active view — fire and forget
|
||||
api('PATCH', '/api/state', { active_view: viewName }).catch(function() {});
|
||||
}
|
||||
@@ -1438,21 +1542,6 @@ function renderGrid(sessions) {
|
||||
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
|
||||
else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty');
|
||||
});
|
||||
// Add Sessions affordance tile — shown in user views only
|
||||
if (_activeView !== 'all' && _activeView !== 'hidden') {
|
||||
var viewsArr = (_serverSettings && _serverSettings.views) || [];
|
||||
var isUserView = false;
|
||||
for (var vi = 0; vi < viewsArr.length; vi++) {
|
||||
if (viewsArr[vi].name === _activeView) { isUserView = true; break; }
|
||||
}
|
||||
if (isUserView) {
|
||||
html += '<button class="add-sessions-tile" onclick="window.MuxplexApp.openAddSessionsPanel()" aria-label="Add sessions to this view">';
|
||||
html += '<span class="add-sessions-tile__icon">+</span>';
|
||||
html += '<span class="add-sessions-tile__label">Add Sessions</span>';
|
||||
html += '</button>';
|
||||
}
|
||||
}
|
||||
|
||||
if (grid) grid.innerHTML = html + statusTilesHtml;
|
||||
|
||||
// Clear filter bar (filtered mode removed; bar is a no-op for flat/grouped)
|
||||
@@ -1922,10 +2011,6 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
|
||||
}
|
||||
|
||||
var views = (_serverSettings && _serverSettings.views) || [];
|
||||
if (views.length === 0) {
|
||||
showToast('No user views. Create one from the header dropdown.');
|
||||
return;
|
||||
}
|
||||
|
||||
var sessionKey = _flyoutSessionKey;
|
||||
// When in a user view, filter it out — the user already has "Remove from [ViewName]" for it
|
||||
@@ -1940,6 +2025,11 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
|
||||
html += escapeHtml(v.name);
|
||||
html += '</button>';
|
||||
}
|
||||
// — Always show "+ New View" option at the bottom
|
||||
if (views.length > 0) {
|
||||
html += '<div class="flyout-menu__separator" role="separator"></div>';
|
||||
}
|
||||
html += '<button class="flyout-submenu__item" role="menuitem" data-action="new-view-in-flyout">+ New View</button>';
|
||||
|
||||
var submenu = document.createElement('div');
|
||||
submenu.className = 'flyout-submenu';
|
||||
@@ -1971,6 +2061,50 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
|
||||
|
||||
// Click handler — toggle view membership via PATCH /api/settings
|
||||
submenu.addEventListener('click', function(e) {
|
||||
// Handle '+ New View' action
|
||||
var newViewAction = e.target.closest('[data-action="new-view-in-flyout"]');
|
||||
if (newViewAction) {
|
||||
var capturedKey = sessionKey;
|
||||
var capturedUnhide = unhideFirst;
|
||||
closeFlyoutMenu();
|
||||
var newName = prompt('View name:');
|
||||
if (!newName || !newName.trim()) return;
|
||||
newName = newName.trim();
|
||||
if (newName.toLowerCase() === 'all' || newName.toLowerCase() === 'hidden') {
|
||||
showToast('Cannot use reserved name \'' + newName + '\'');
|
||||
return;
|
||||
}
|
||||
var existViews = (_serverSettings && _serverSettings.views) || [];
|
||||
if (existViews.find(function(v) { return v.name === newName; })) {
|
||||
showToast('View \'' + newName + '\' already exists');
|
||||
return;
|
||||
}
|
||||
var newView = { name: newName, sessions: [capturedKey] };
|
||||
var newViews = existViews.concat([newView]);
|
||||
var flyoutPatch = { views: newViews };
|
||||
if (capturedUnhide) {
|
||||
var hiddenList = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
||||
var hi = hiddenList.indexOf(capturedKey);
|
||||
if (hi !== -1) {
|
||||
var updHidden = hiddenList.slice();
|
||||
updHidden.splice(hi, 1);
|
||||
flyoutPatch.hidden_sessions = updHidden;
|
||||
}
|
||||
}
|
||||
api('PATCH', '/api/settings', flyoutPatch)
|
||||
.then(function() {
|
||||
if (_serverSettings) {
|
||||
_serverSettings.views = newViews;
|
||||
if (flyoutPatch.hidden_sessions) _serverSettings.hidden_sessions = flyoutPatch.hidden_sessions;
|
||||
}
|
||||
switchView(newName);
|
||||
})
|
||||
.catch(function() {
|
||||
showToast('Failed to create view');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var btn = e.target.closest('[data-view-index]');
|
||||
if (!btn) return;
|
||||
var idx = parseInt(btn.dataset.viewIndex, 10);
|
||||
@@ -2252,6 +2386,28 @@ function closeAddSessionsPanel() {
|
||||
if (panel) panel.classList.add('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the header #add-sessions-btn based on the active view.
|
||||
* Visible only when in a user-created view (not 'all' or 'hidden').
|
||||
* Called from switchView() and on initial load.
|
||||
*/
|
||||
function updateAddSessionsButton() {
|
||||
var btn = $('add-sessions-btn');
|
||||
if (!btn) return;
|
||||
var isUserView = false;
|
||||
if (_activeView !== 'all' && _activeView !== 'hidden') {
|
||||
var views = (_serverSettings && _serverSettings.views) || [];
|
||||
for (var i = 0; i < views.length; i++) {
|
||||
if (views[i].name === _activeView) { isUserView = true; break; }
|
||||
}
|
||||
}
|
||||
if (isUserView) {
|
||||
btn.classList.remove('hidden');
|
||||
} else {
|
||||
btn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the session list inside the Add Sessions panel.
|
||||
* Shows all sessions NOT currently in the active view.
|
||||
@@ -3763,10 +3919,28 @@ function bindStaticEventListeners() {
|
||||
// Close sidebar dropdown after selection
|
||||
sidebarViewMenu.classList.add('hidden');
|
||||
if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false');
|
||||
return;
|
||||
}
|
||||
var action = e.target.closest('[data-action]');
|
||||
if (action) {
|
||||
if (action.dataset.action === 'new-view') {
|
||||
showSidebarNewViewInput();
|
||||
} else if (action.dataset.action === 'manage-views') {
|
||||
sidebarViewMenu.classList.add('hidden');
|
||||
if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false');
|
||||
openSettings();
|
||||
switchSettingsTab('views');
|
||||
} else {
|
||||
sidebarViewMenu.classList.add('hidden');
|
||||
if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var addSessionsBtn = $('add-sessions-btn');
|
||||
if (addSessionsBtn) on(addSessionsBtn, 'click', openAddSessionsPanel);
|
||||
|
||||
// Click-outside closes the header view dropdown
|
||||
document.addEventListener('click', function(e) {
|
||||
var dropdown = $('view-dropdown-menu');
|
||||
@@ -4104,6 +4278,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
updatePageTitle();
|
||||
startHeartbeat();
|
||||
bindStaticEventListeners();
|
||||
updateAddSessionsButton();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[init] restoreState failed, retrying in 5s:', err);
|
||||
@@ -4200,6 +4375,9 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
// Sidebar view dropdown
|
||||
renderSidebarViewDropdown,
|
||||
toggleSidebarViewDropdown,
|
||||
showSidebarNewViewInput,
|
||||
// Add Sessions header button
|
||||
updateAddSessionsButton,
|
||||
// Manage Views settings tab
|
||||
renderViewsSettingsTab,
|
||||
_saveViewsAndRerender,
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<div id="view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button id="add-sessions-btn" class="header-btn hidden" aria-label="Add sessions to view">+ Add</button>
|
||||
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
|
||||
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto">▦</button>
|
||||
<button id="settings-btn" class="header-btn" aria-label="Settings">⚙</button>
|
||||
|
||||
@@ -208,6 +208,7 @@ body {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
@@ -2216,9 +2217,7 @@ body {
|
||||
/* —— Tile Flyout Menu ————————————————————————————————————————————————————— */
|
||||
|
||||
.tile-options-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
@@ -2231,7 +2230,6 @@ body {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
|
||||
|
||||
@@ -2614,30 +2614,36 @@ test('buildTileHTML escapes HTML in deviceName within device-badge', () => {
|
||||
|
||||
// --- buildTileHTML device badge placement (task-3) ---
|
||||
|
||||
test('buildTileHTML places device-badge inside tile-meta span', () => {
|
||||
test('buildTileHTML places device-badge inline in tile-header (before tile-meta)', () => {
|
||||
app._setServerSettings({ multi_device_enabled: true });
|
||||
const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' };
|
||||
const html = app.buildTileHTML(session, 0, false);
|
||||
const tileMetaStart = html.indexOf('<span class="tile-meta">');
|
||||
// Note: finds the first </span> after tileMetaStart, which is device-badge's closing tag
|
||||
// (not tile-meta's own close), but the assertion still holds because device-badge
|
||||
// opens and closes before tile-time within the tile-meta container.
|
||||
const tileMetaEnd = html.indexOf('</span>', tileMetaStart);
|
||||
assert.ok(tileMetaStart !== -1, 'tile-meta span should exist');
|
||||
// Badge is now a sibling flex item inside tile-header, after tile-name, before tile-meta/button
|
||||
const tileHeaderStart = html.indexOf('<div class="tile-header">');
|
||||
const tileHeaderEnd = html.indexOf('</div>', tileHeaderStart);
|
||||
assert.ok(tileHeaderStart !== -1, 'tile-header div should exist');
|
||||
const deviceBadgePos = html.indexOf('device-badge');
|
||||
assert.ok(deviceBadgePos !== -1, 'device-badge should exist in tile HTML');
|
||||
assert.ok(
|
||||
deviceBadgePos > tileMetaStart && deviceBadgePos < tileMetaEnd,
|
||||
`device-badge should be inside tile-meta span (tile-meta starts at ${tileMetaStart}, device-badge at ${deviceBadgePos}, tile-meta closes at ${tileMetaEnd})`
|
||||
deviceBadgePos > tileHeaderStart && deviceBadgePos < tileHeaderEnd,
|
||||
`device-badge should be inside tile-header (tile-header starts at ${tileHeaderStart}, device-badge at ${deviceBadgePos}, tile-header closes at ${tileHeaderEnd})`
|
||||
);
|
||||
app._setServerSettings(null);
|
||||
});
|
||||
|
||||
test('buildTileHTML includes tile-meta-sep with middle dot when badge present', () => {
|
||||
test('buildTileHTML badge and options-btn are siblings in tile-header when badge present', () => {
|
||||
// Since the badge moved out of tile-meta into tile-header directly, tile-meta-sep is removed.
|
||||
// The tile-options-btn is also now inside tile-header as a flex sibling.
|
||||
app._setServerSettings({ multi_device_enabled: true });
|
||||
const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' };
|
||||
const html = app.buildTileHTML(session, 0, false);
|
||||
assert.ok(html.includes('tile-meta-sep'), 'should include tile-meta-sep element when badge is present');
|
||||
assert.ok(html.includes('\u00b7'), 'should include middle dot separator (\u00b7)');
|
||||
// Badge is directly in tile-header (not inside tile-meta)
|
||||
assert.ok(html.includes('device-badge'), 'device-badge should exist in tile HTML');
|
||||
// tile-options-btn is also inside tile-header (before tile-body)
|
||||
const headerStart = html.indexOf('<div class="tile-header">');
|
||||
const bodyStart = html.indexOf('<div class="tile-body">');
|
||||
const btnPos = html.indexOf('tile-options-btn');
|
||||
assert.ok(btnPos > headerStart && btnPos < bodyStart, 'tile-options-btn should be inside tile-header (before tile-body)');
|
||||
app._setServerSettings(null);
|
||||
});
|
||||
|
||||
|
||||
@@ -3948,10 +3948,22 @@ def test_open_flyout_menu_checks_mobile() -> None:
|
||||
|
||||
|
||||
def test_render_grid_has_add_sessions_affordance() -> None:
|
||||
"""renderGrid must include an 'Add Sessions' affordance when in a user view."""
|
||||
"""app.js must have an 'Add Sessions' affordance for user views.
|
||||
|
||||
The affordance moved from a tile in renderGrid to a header button (#add-sessions-btn)
|
||||
managed by updateAddSessionsButton(). Either the old tile approach or the new header
|
||||
button approach satisfies this requirement.
|
||||
"""
|
||||
fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
|
||||
assert "add-sessions" in fn_body.lower() or "openAddSessionsPanel" in fn_body, (
|
||||
"renderGrid must render an 'Add Sessions' affordance for user views"
|
||||
# Old approach: tile in the grid | New approach: header button via updateAddSessionsButton
|
||||
has_affordance = (
|
||||
"add-sessions" in fn_body.lower()
|
||||
or "openAddSessionsPanel" in fn_body
|
||||
or "updateAddSessionsButton" in _JS
|
||||
)
|
||||
assert has_affordance, (
|
||||
"app.js must have an 'Add Sessions' affordance for user views — "
|
||||
"either as a tile in renderGrid or as a header button via updateAddSessionsButton"
|
||||
)
|
||||
|
||||
|
||||
@@ -4244,3 +4256,235 @@ def test_kill_confirm_buttons_use_role_button() -> None:
|
||||
assert 'data-action="cancel" role="button"' in _JS, (
|
||||
"Cancel button inside alertdialog must use role='button'"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# UX Refinements from live testing (6 issues)
|
||||
# ============================================================
|
||||
|
||||
CSS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "style.css"
|
||||
_CSS: str = CSS_PATH.read_text()
|
||||
|
||||
|
||||
# — Issue 1: Sidebar dropdown "+ New View" ——————————————————————————
|
||||
|
||||
def test_render_sidebar_view_dropdown_has_new_view_action() -> None:
|
||||
"""renderSidebarViewDropdown must include a '+ New View' action button."""
|
||||
match = re.search(
|
||||
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "renderSidebarViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert 'data-action="new-view"' in body, (
|
||||
'renderSidebarViewDropdown must include a "+ New View" button with data-action="new-view"'
|
||||
)
|
||||
|
||||
|
||||
def test_show_sidebar_new_view_input_function_exists() -> None:
|
||||
"""showSidebarNewViewInput function must exist in app.js."""
|
||||
assert "function showSidebarNewViewInput" in _JS, (
|
||||
"showSidebarNewViewInput must be defined in app.js"
|
||||
)
|
||||
|
||||
|
||||
def test_bind_static_event_listeners_calls_show_sidebar_new_view_input() -> None:
|
||||
"""bindStaticEventListeners sidebar dropdown handler must call showSidebarNewViewInput."""
|
||||
match = re.search(
|
||||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "bindStaticEventListeners function not found"
|
||||
body = match.group(1)
|
||||
assert "showSidebarNewViewInput" in body, (
|
||||
"bindStaticEventListeners must call showSidebarNewViewInput for sidebar new-view action"
|
||||
)
|
||||
|
||||
|
||||
# — Issue 2: Remove shortcut numbers, add session counts ———————————————
|
||||
|
||||
def test_render_view_dropdown_no_shortcut_spans() -> None:
|
||||
"""renderViewDropdown must not include view-dropdown__shortcut spans (numbers removed)."""
|
||||
match = re.search(
|
||||
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "renderViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "view-dropdown__shortcut" not in body, (
|
||||
"renderViewDropdown must not include view-dropdown__shortcut spans — shortcut numbers removed"
|
||||
)
|
||||
|
||||
|
||||
def test_render_sidebar_view_dropdown_no_shortcut_spans() -> None:
|
||||
"""renderSidebarViewDropdown must not include view-dropdown__shortcut spans."""
|
||||
match = re.search(
|
||||
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "renderSidebarViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "view-dropdown__shortcut" not in body, (
|
||||
"renderSidebarViewDropdown must not include view-dropdown__shortcut spans — shortcut numbers removed"
|
||||
)
|
||||
|
||||
|
||||
def test_render_view_dropdown_shows_user_view_session_count() -> None:
|
||||
"""renderViewDropdown must show session count for user views."""
|
||||
match = re.search(
|
||||
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "renderViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "sessions.length" in body or "sessions || []).length" in body, (
|
||||
"renderViewDropdown must show session count for user views (view.sessions.length)"
|
||||
)
|
||||
|
||||
|
||||
# — Issue 3: Empty new view opens Add Sessions panel ———————————————————
|
||||
|
||||
def test_show_new_view_input_calls_open_add_sessions_panel() -> None:
|
||||
"""showNewViewInput must call openAddSessionsPanel after creating a new view."""
|
||||
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 "openAddSessionsPanel" in body, (
|
||||
"showNewViewInput must call openAddSessionsPanel() after creating a new view — "
|
||||
"so the user immediately sees the Add Sessions panel for their empty view"
|
||||
)
|
||||
|
||||
|
||||
def test_show_sidebar_new_view_input_calls_open_add_sessions_panel() -> None:
|
||||
"""showSidebarNewViewInput must call openAddSessionsPanel after creating a new view."""
|
||||
match = re.search(
|
||||
r"function showSidebarNewViewInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "showSidebarNewViewInput function not found"
|
||||
body = match.group(1)
|
||||
assert "openAddSessionsPanel" in body, (
|
||||
"showSidebarNewViewInput must call openAddSessionsPanel() after creating a new view"
|
||||
)
|
||||
|
||||
|
||||
# — Issue 4: Flyout submenu "+ New View" ———————————————————————————————
|
||||
|
||||
def test_open_flyout_submenu_has_new_view_option() -> None:
|
||||
"""_openFlyoutSubmenu must include a '+ New View' option."""
|
||||
match = re.search(
|
||||
r"function _openFlyoutSubmenu\s*\(.*?\)\s*\{(.*?)\n\}",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "_openFlyoutSubmenu function not found"
|
||||
body = match.group(1)
|
||||
assert "new-view-in-flyout" in body, (
|
||||
'_openFlyoutSubmenu must include a "+ New View" option with data-action="new-view-in-flyout"'
|
||||
)
|
||||
|
||||
|
||||
def test_open_flyout_submenu_new_view_creates_and_switches() -> None:
|
||||
"""_openFlyoutSubmenu '+ New View' handler must create a view and switch to it."""
|
||||
match = re.search(
|
||||
r"function _openFlyoutSubmenu\s*\(.*?\)\s*\{(.*?)\n\}",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "_openFlyoutSubmenu function not found"
|
||||
body = match.group(1)
|
||||
assert "new-view-in-flyout" in body, (
|
||||
"'new-view-in-flyout' not found in _openFlyoutSubmenu"
|
||||
)
|
||||
# switchView and PATCH must appear in the function body (the handler for new-view-in-flyout)
|
||||
assert "switchView" in body, (
|
||||
'_openFlyoutSubmenu must call switchView — the "+ New View" handler needs to switch to the new view'
|
||||
)
|
||||
assert "PATCH" in body or "api(" in body, (
|
||||
'_openFlyoutSubmenu must PATCH /api/settings to create the view'
|
||||
)
|
||||
|
||||
|
||||
# — Issue 5: Add Sessions header button ————————————————————————————————
|
||||
|
||||
def test_update_add_sessions_button_function_exists() -> None:
|
||||
"""updateAddSessionsButton function must exist in app.js."""
|
||||
assert "function updateAddSessionsButton" in _JS, (
|
||||
"updateAddSessionsButton must be defined in app.js — "
|
||||
"shows/hides the '+ Add' header button based on active view"
|
||||
)
|
||||
|
||||
|
||||
def test_switch_view_calls_update_add_sessions_button() -> None:
|
||||
"""switchView must call updateAddSessionsButton to update header button visibility."""
|
||||
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 "updateAddSessionsButton" in body, (
|
||||
"switchView must call updateAddSessionsButton() to show/hide the header Add button"
|
||||
)
|
||||
|
||||
|
||||
def test_render_grid_no_muxplex_app_onclick() -> None:
|
||||
"""renderGrid must not use the broken window.MuxplexApp.openAddSessionsPanel onclick."""
|
||||
fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
|
||||
assert "MuxplexApp.openAddSessionsPanel" not in fn_body, (
|
||||
"renderGrid must not use onclick='window.MuxplexApp.openAddSessionsPanel()' — "
|
||||
"this was broken; the Add Sessions entry point moved to the header button"
|
||||
)
|
||||
|
||||
|
||||
def test_bind_static_event_listeners_binds_add_sessions_btn() -> None:
|
||||
"""bindStaticEventListeners must bind #add-sessions-btn click to openAddSessionsPanel."""
|
||||
match = re.search(
|
||||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "bindStaticEventListeners function not found"
|
||||
body = match.group(1)
|
||||
assert "add-sessions-btn" in body, (
|
||||
"bindStaticEventListeners must bind #add-sessions-btn click to openAddSessionsPanel"
|
||||
)
|
||||
|
||||
|
||||
# — Issue 6: Tile header flexbox layout ————————————————————————————————
|
||||
|
||||
def test_build_tile_html_options_btn_inside_tile_header() -> None:
|
||||
"""buildTileHTML must render tile-options-btn inside tile-header (before tile-body)."""
|
||||
fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0]
|
||||
tile_opts_pos = fn_body.find("tile-options-btn")
|
||||
tile_body_pos = fn_body.find("tile-body")
|
||||
assert tile_opts_pos >= 0, "tile-options-btn must appear in buildTileHTML"
|
||||
assert tile_body_pos >= 0, "tile-body must appear in buildTileHTML"
|
||||
assert tile_opts_pos < tile_body_pos, (
|
||||
"tile-options-btn must appear before tile-body in the HTML string — "
|
||||
"it must be inside tile-header (as an inline flex item), not positioned after tile-body"
|
||||
)
|
||||
|
||||
|
||||
def test_tile_options_btn_css_not_absolute() -> None:
|
||||
"""CSS .tile-options-btn must not use position:absolute — prevent badge/button overlap."""
|
||||
import re as _re
|
||||
|
||||
match = _re.search(r"\.tile-options-btn\s*\{([^}]*)\}", _CSS, _re.DOTALL)
|
||||
assert match, ".tile-options-btn CSS rule not found"
|
||||
rule_body = match.group(1)
|
||||
assert "position: absolute" not in rule_body and "position:absolute" not in rule_body, (
|
||||
".tile-options-btn must not use position:absolute — "
|
||||
"it should be an inline flex item inside tile-header to prevent device badge overlap"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user