feat: add showNewViewInput inline creation flow for New View dropdown

- Add showNewViewInput() function after closeViewDropdown() in app.js
  - Creates inline text input replacing '+ New View' button in dropdown
  - Input: type=text, class=view-dropdown__new-input, placeholder='View name',
    maxLength=30, aria-label='New view name'
  - Re-focuses existing input if already present (prevents duplicates)
  - On Enter: validates name (non-empty, non-reserved, non-duplicate),
    PATCHes /api/settings with updated views, switches to new view on success
  - On Escape: closes the dropdown
  - On blur: closes dropdown after 150ms delay if input not focused
- Update bindStaticEventListeners to call showNewViewInput() for data-action='new-view'
  instead of just closeViewDropdown()
- Export showNewViewInput in module.exports block

Tests added:
- test_show_new_view_input_function_exists: verifies function exists in app.js
- test_show_new_view_input_patches_settings: verifies PATCH /api/settings with views

Task: task-8
This commit is contained in:
Brian Krabach
2026-04-15 18:08:43 -07:00
parent a891fada46
commit 5732436f88
2 changed files with 119 additions and 2 deletions
+87 -2
View File
@@ -898,6 +898,87 @@ function closeViewDropdown() {
if (trigger) trigger.setAttribute('aria-expanded', 'false'); 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, * 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.
@@ -2439,8 +2520,11 @@ function bindStaticEventListeners() {
} }
var action = e.target.closest('[data-action]'); var action = e.target.closest('[data-action]');
if (action) { if (action) {
// action handlers (new-view, manage-views) — placeholder for future tasks if (action.dataset.action === 'new-view') {
closeViewDropdown(); showNewViewInput();
} else {
closeViewDropdown();
}
} }
}); });
} }
@@ -2870,6 +2954,7 @@ if (typeof module !== 'undefined' && module.exports) {
renderViewDropdown, renderViewDropdown,
toggleViewDropdown, toggleViewDropdown,
closeViewDropdown, closeViewDropdown,
showNewViewInput,
switchView, switchView,
// Federation tiles // Federation tiles
buildStatusTileHTML, buildStatusTileHTML,
+32
View File
@@ -3229,3 +3229,35 @@ def test_backtick_only_on_grid_not_fullscreen() -> None:
assert "'grid'" in body, ( assert "'grid'" in body, (
"handleGlobalKeydown must check _viewMode === 'grid' before handling backtick/number shortcuts" "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"
)