feat: add Views settings tab with inline rename, reorder, and delete

Implements the "Manage Views" Settings Tab with full UI and functionality:

HTML: Added Views tab button and panel with empty state message.
JS: Added renderViewsSettingsTab() function that renders the view list with:
- Clickable name for inline rename (validates reserved/duplicate names)
- Session count display
- Up/down arrow buttons for reordering (disabled at boundaries)
- Delete button with inline 'Sure? [Yes] [No]' confirmation
- Delegated event handlers for all interactions
Added _saveViewsAndRerender() helper that PATCHes /api/settings,
updates _serverSettings, and re-renders both tab and dropdown.
Updated openSettings() to call renderViewsSettingsTab() after loading.
CSS: Added styling for views-settings-list, views-settings-row,
views-settings-name, views-settings-count, views-settings-actions,
views-settings-btn, views-settings-confirm, and rename input.
Tests: Added 3 new frontend tests and updated existing panel count test.

All 491 frontend tests pass with no regressions.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
This commit is contained in:
Brian Krabach
2026-04-15 18:23:56 -07:00
parent 5732436f88
commit 2c04c2600a
5 changed files with 403 additions and 3 deletions
+251 -1
View File
@@ -892,7 +892,7 @@ function closeViewDropdown() {
if (menu) { if (menu) {
menu.classList.add('hidden'); menu.classList.add('hidden');
// Remove any inline new-view input // Remove any inline new-view input
var newViewInput = menu.querySelector('.view-dropdown-new-input'); var newViewInput = menu.querySelector('.view-dropdown__new-input');
if (newViewInput) newViewInput.remove(); if (newViewInput) newViewInput.remove();
} }
if (trigger) trigger.setAttribute('aria-expanded', 'false'); if (trigger) trigger.setAttribute('aria-expanded', 'false');
@@ -979,6 +979,250 @@ function showNewViewInput() {
}); });
} }
/**
* Save updated views array via PATCH /api/settings, update _serverSettings,
* re-render the views settings tab, and re-render the view dropdown.
* @param {Array} updatedViews - New views array to save.
*/
function _saveViewsAndRerender(updatedViews) {
return api('PATCH', '/api/settings', { views: updatedViews })
.then(function() {
if (_serverSettings) _serverSettings.views = updatedViews;
renderViewsSettingsTab();
renderViewDropdown();
})
.catch(function() {
showToast('Failed to save views');
});
}
/**
* Render the Views settings tab content.
* Reads views from _serverSettings and builds an interactive list
* with inline rename, up/down reorder, and delete with confirmation.
*/
function renderViewsSettingsTab() {
var listEl = $('views-settings-list');
var emptyEl = $('views-settings-empty');
if (!listEl) return;
var views = (_serverSettings && _serverSettings.views) || [];
if (views.length === 0) {
listEl.innerHTML = '';
if (emptyEl) emptyEl.style.display = '';
return;
}
if (emptyEl) emptyEl.style.display = 'none';
// Build the list of view rows
listEl.innerHTML = '';
views.forEach(function(view, idx) {
var sessions = (_serverSettings && _serverSettings.sessions) || [];
// Count sessions that belong to this view
var viewSessions = view.sessions || [];
var sessionCount = viewSessions.length;
var row = document.createElement('div');
row.className = 'views-settings-row';
row.setAttribute('data-view-idx', String(idx));
// Name span (clickable for rename)
var nameSpan = document.createElement('span');
nameSpan.className = 'views-settings-name';
nameSpan.textContent = view.name;
nameSpan.title = 'Click to rename';
// Session count
var countSpan = document.createElement('span');
countSpan.className = 'views-settings-count';
countSpan.textContent = sessionCount + (sessionCount === 1 ? ' session' : ' sessions');
// Actions container
var actionsDiv = document.createElement('div');
actionsDiv.className = 'views-settings-actions';
// Up button
var upBtn = document.createElement('button');
upBtn.className = 'views-settings-btn';
upBtn.textContent = '▲';
upBtn.title = 'Move up';
upBtn.setAttribute('data-action', 'move-up');
upBtn.setAttribute('data-idx', String(idx));
if (idx === 0) upBtn.disabled = true;
// Down button
var downBtn = document.createElement('button');
downBtn.className = 'views-settings-btn';
downBtn.textContent = '▼';
downBtn.title = 'Move down';
downBtn.setAttribute('data-action', 'move-down');
downBtn.setAttribute('data-idx', String(idx));
if (idx === views.length - 1) downBtn.disabled = true;
// Delete button
var deleteBtn = document.createElement('button');
deleteBtn.className = 'views-settings-btn views-settings-btn--danger';
deleteBtn.textContent = 'Delete';
deleteBtn.setAttribute('data-action', 'delete');
deleteBtn.setAttribute('data-idx', String(idx));
actionsDiv.appendChild(upBtn);
actionsDiv.appendChild(downBtn);
actionsDiv.appendChild(deleteBtn);
row.appendChild(nameSpan);
row.appendChild(countSpan);
row.appendChild(actionsDiv);
listEl.appendChild(row);
});
// Delegated click handler on the list
listEl.onclick = function(e) {
var views = (_serverSettings && _serverSettings.views) || [];
var target = e.target;
// Move up
if (target.getAttribute('data-action') === 'move-up') {
var idx = parseInt(target.getAttribute('data-idx'), 10);
if (idx > 0) {
var updated = views.slice();
var tmp = updated[idx - 1];
updated[idx - 1] = updated[idx];
updated[idx] = tmp;
_saveViewsAndRerender(updated);
}
return;
}
// Move down
if (target.getAttribute('data-action') === 'move-down') {
var idx = parseInt(target.getAttribute('data-idx'), 10);
if (idx < views.length - 1) {
var updated = views.slice();
var tmp = updated[idx + 1];
updated[idx + 1] = updated[idx];
updated[idx] = tmp;
_saveViewsAndRerender(updated);
}
return;
}
// Delete: show inline confirm
if (target.getAttribute('data-action') === 'delete') {
var idx = parseInt(target.getAttribute('data-idx'), 10);
var row = listEl.querySelector('[data-view-idx="' + idx + '"]');
if (!row) return;
// Replace delete button with "Sure? [Yes] [No]"
var actionsDiv = row.querySelector('.views-settings-actions');
if (!actionsDiv) return;
actionsDiv.innerHTML = '';
var confirmSpan = document.createElement('span');
confirmSpan.className = 'views-settings-confirm';
confirmSpan.textContent = 'Sure? ';
var yesBtn = document.createElement('button');
yesBtn.className = 'views-settings-btn views-settings-btn--danger';
yesBtn.textContent = 'Yes';
yesBtn.setAttribute('data-action', 'confirm-delete');
yesBtn.setAttribute('data-idx', String(idx));
var noBtn = document.createElement('button');
noBtn.className = 'views-settings-btn';
noBtn.textContent = 'No';
noBtn.setAttribute('data-action', 'cancel-delete');
confirmSpan.appendChild(yesBtn);
confirmSpan.appendChild(document.createTextNode(' '));
confirmSpan.appendChild(noBtn);
actionsDiv.appendChild(confirmSpan);
return;
}
// Confirm delete
if (target.getAttribute('data-action') === 'confirm-delete') {
var idx = parseInt(target.getAttribute('data-idx'), 10);
var updated = views.slice();
updated.splice(idx, 1);
// If deleting the active view, fall back to 'all'
if (_activeView === views[idx].name) {
_activeView = 'all';
}
_saveViewsAndRerender(updated);
return;
}
// Cancel delete: re-render
if (target.getAttribute('data-action') === 'cancel-delete') {
renderViewsSettingsTab();
return;
}
// Click on name: inline rename
if (target.classList.contains('views-settings-name')) {
var row = target.closest('.views-settings-row');
if (!row) return;
var idx = parseInt(row.getAttribute('data-view-idx'), 10);
var currentName = views[idx] && views[idx].name;
if (currentName === undefined) return;
var input = document.createElement('input');
input.type = 'text';
input.className = 'views-settings-rename-input';
input.value = currentName;
target.parentNode.replaceChild(input, target);
input.focus();
input.select();
function commitRename() {
var newName = input.value.trim();
if (!newName || newName === currentName) {
renderViewsSettingsTab();
return;
}
// Validate: not reserved
if (newName === 'all' || newName === 'hidden') {
showToast('Cannot use reserved name \'' + newName + '\'');
renderViewsSettingsTab();
return;
}
// Validate: not duplicate
var duplicate = views.find(function(v, i) { return i !== idx && v.name === newName; });
if (duplicate) {
showToast('View \'' + newName + '\' already exists');
renderViewsSettingsTab();
return;
}
// Update the view name
var updated = views.map(function(v, i) {
return i === idx ? Object.assign({}, v, { name: newName }) : v;
});
// If this was the active view, update _activeView
if (_activeView === currentName) {
_activeView = newName;
}
_saveViewsAndRerender(updated);
}
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
commitRename();
} else if (e.key === 'Escape') {
renderViewsSettingsTab();
}
});
input.addEventListener('blur', function() {
commitRename();
});
}
};
}
/** /**
* Switch to a named view. Updates _activeView, re-renders the grid and sidebar, * 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. * updates the dropdown label, and persists the change via PATCH /api/state.
@@ -1990,6 +2234,9 @@ function openSettings() {
if (deleteTemplateEl) { if (deleteTemplateEl) {
deleteTemplateEl.value = (ss && ss.delete_session_template) || DELETE_SESSION_DEFAULT_TEMPLATE; deleteTemplateEl.value = (ss && ss.delete_session_template) || DELETE_SESSION_DEFAULT_TEMPLATE;
} }
// Views tab
renderViewsSettingsTab();
}); });
} }
@@ -2956,6 +3203,9 @@ if (typeof module !== 'undefined' && module.exports) {
closeViewDropdown, closeViewDropdown,
showNewViewInput, showNewViewInput,
switchView, switchView,
// Manage Views settings tab
renderViewsSettingsTab,
_saveViewsAndRerender,
// Federation tiles // Federation tiles
buildStatusTileHTML, buildStatusTileHTML,
// Constants // Constants
+5
View File
@@ -103,6 +103,7 @@
<nav class="settings-tabs"> <nav class="settings-tabs">
<button class="settings-tab settings-tab--active" data-tab="display">Display</button> <button class="settings-tab settings-tab--active" data-tab="display">Display</button>
<button class="settings-tab" data-tab="sessions">Sessions</button> <button class="settings-tab" data-tab="sessions">Sessions</button>
<button class="settings-tab" data-tab="views">Views</button>
<button class="settings-tab" data-tab="new-session">Commands</button> <button class="settings-tab" data-tab="new-session">Commands</button>
<button class="settings-tab" data-tab="devices">Multi-Device</button> <button class="settings-tab" data-tab="devices">Multi-Device</button>
</nav> </nav>
@@ -199,6 +200,10 @@
<div id="setting-hidden-sessions" class="settings-checkbox-list"></div> <div id="setting-hidden-sessions" class="settings-checkbox-list"></div>
</div> </div>
</div> </div>
<div class="settings-panel hidden" data-tab="views">
<div id="views-settings-list" class="views-settings-list"></div>
<p class="settings-helper" id="views-settings-empty" style="display:none">No user-created views yet. Use the dropdown in the header to create one.</p>
</div>
<div class="settings-panel hidden" data-tab="new-session"> <div class="settings-panel hidden" data-tab="new-session">
<div class="settings-field settings-field--column"> <div class="settings-field settings-field--column">
<label class="settings-label" for="setting-template">Command template</label> <label class="settings-label" for="setting-template">Command template</label>
+96
View File
@@ -2001,3 +2001,99 @@ body {
border-radius: 4px; border-radius: 4px;
color: var(--text); color: var(--text);
} }
/* ─── Manage Views settings tab (task-9) ─────────────────────────────────── */
.views-settings-list {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.views-settings-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
}
.views-settings-name {
flex: 1;
font-size: 13px;
color: var(--text);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.views-settings-name:hover {
color: var(--accent);
text-decoration: underline;
}
.views-settings-count {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.views-settings-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.views-settings-btn {
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 3px 7px;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.views-settings-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-dim);
}
.views-settings-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.views-settings-btn--danger:hover:not(:disabled) {
border-color: #ef4444;
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.views-settings-confirm {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
}
.views-settings-rename-input {
flex: 1;
background: var(--bg);
border: 1px solid var(--accent);
border-radius: 4px;
color: var(--text);
font-size: 13px;
padding: 3px 6px;
outline: none;
}
+39 -2
View File
@@ -545,8 +545,8 @@ def test_html_settings_panels_use_data_tab() -> None:
dialog = soup.find(id="settings-dialog") dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog" assert dialog is not None, "Missing #settings-dialog"
panels = dialog.find_all(class_="settings-panel") panels = dialog.find_all(class_="settings-panel")
assert len(panels) == 4, ( assert len(panels) == 5, (
f"Expected 4 .settings-panel elements, found: {len(panels)}" f"Expected 5 .settings-panel elements, found: {len(panels)}"
) )
for panel in panels: for panel in panels:
assert panel.get("data-tab") is not None, ( assert panel.get("data-tab") is not None, (
@@ -1464,3 +1464,40 @@ def test_view_dropdown_menu_has_role_menu() -> None:
assert menu.get("role") == "menu", ( assert menu.get("role") == "menu", (
f"#view-dropdown-menu must have role='menu', got: {menu.get('role')!r}" f"#view-dropdown-menu must have role='menu', got: {menu.get('role')!r}"
) )
# ============================================================
# Manage Views settings tab (task-9)
# ============================================================
def test_settings_has_views_tab_button() -> None:
"""Settings dialog must contain a tab button with data-tab='views'."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
tabs_container = dialog.find("nav", class_="settings-tabs")
assert tabs_container is not None, "Missing nav.settings-tabs"
tab = tabs_container.find("button", attrs={"data-tab": "views"})
assert tab is not None, "Missing tab button with data-tab='views' in settings-tabs"
tab_classes = tab.get("class") or []
assert "settings-tab" in tab_classes, (
f"views tab button must have class 'settings-tab', has: {tab_classes}"
)
def test_settings_has_views_panel() -> None:
"""A settings-panel with data-tab='views' must exist inside #settings-dialog."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
panel = dialog.find(class_="settings-panel", attrs={"data-tab": "views"})
assert panel is not None, (
"Missing settings-panel[data-tab='views'] (Views tab panel)"
)
# Must contain the views settings list
views_list = panel.find(id="views-settings-list")
assert views_list is not None, "Missing #views-settings-list inside views panel"
# Must contain the empty message element
empty_msg = panel.find(id="views-settings-empty")
assert empty_msg is not None, "Missing #views-settings-empty inside views panel"
+12
View File
@@ -3261,3 +3261,15 @@ def test_show_new_view_input_patches_settings() -> None:
assert "views" in body, ( assert "views" in body, (
"showNewViewInput must include 'views' in the PATCH body" "showNewViewInput must include 'views' in the PATCH body"
) )
# ============================================================
# Manage Views settings tab (task-9)
# ============================================================
def test_render_views_settings_tab_function_exists() -> None:
"""renderViewsSettingsTab function must exist in app.js."""
assert "function renderViewsSettingsTab" in _JS, (
"renderViewsSettingsTab must be defined in app.js"
)