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:
+251
-1
@@ -892,7 +892,7 @@ function closeViewDropdown() {
|
||||
if (menu) {
|
||||
menu.classList.add('hidden');
|
||||
// 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 (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,
|
||||
* updates the dropdown label, and persists the change via PATCH /api/state.
|
||||
@@ -1990,6 +2234,9 @@ function openSettings() {
|
||||
if (deleteTemplateEl) {
|
||||
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,
|
||||
showNewViewInput,
|
||||
switchView,
|
||||
// Manage Views settings tab
|
||||
renderViewsSettingsTab,
|
||||
_saveViewsAndRerender,
|
||||
// Federation tiles
|
||||
buildStatusTileHTML,
|
||||
// Constants
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
<nav class="settings-tabs">
|
||||
<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="views">Views</button>
|
||||
<button class="settings-tab" data-tab="new-session">Commands</button>
|
||||
<button class="settings-tab" data-tab="devices">Multi-Device</button>
|
||||
</nav>
|
||||
@@ -199,6 +200,10 @@
|
||||
<div id="setting-hidden-sessions" class="settings-checkbox-list"></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-field settings-field--column">
|
||||
<label class="settings-label" for="setting-template">Command template</label>
|
||||
|
||||
@@ -2001,3 +2001,99 @@ body {
|
||||
border-radius: 4px;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -545,8 +545,8 @@ def test_html_settings_panels_use_data_tab() -> None:
|
||||
dialog = soup.find(id="settings-dialog")
|
||||
assert dialog is not None, "Missing #settings-dialog"
|
||||
panels = dialog.find_all(class_="settings-panel")
|
||||
assert len(panels) == 4, (
|
||||
f"Expected 4 .settings-panel elements, found: {len(panels)}"
|
||||
assert len(panels) == 5, (
|
||||
f"Expected 5 .settings-panel elements, found: {len(panels)}"
|
||||
)
|
||||
for panel in panels:
|
||||
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", (
|
||||
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"
|
||||
|
||||
@@ -3261,3 +3261,15 @@ def test_show_new_view_input_patches_settings() -> None:
|
||||
assert "views" in 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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user