feat: add Sessions settings tab with server-side persistence

This commit is contained in:
Brian Krabach
2026-03-29 23:57:22 -07:00
parent f380307f08
commit c955aadad1
6 changed files with 500 additions and 1 deletions
+127
View File
@@ -132,6 +132,7 @@ var _previewSessionName = null; // track by NAME, not DOM element
// ─── Settings state ───────────────────────────────────────────────────────────
let _settingsOpen = false;
let _serverSettings = null;
const DISPLAY_SETTINGS_KEY = 'muxplex.display';
const DISPLAY_DEFAULTS = {
fontSize: 14,
@@ -909,6 +910,42 @@ function _setViewingSession(name) {
_viewingSession = name;
}
// ─── Server settings ─────────────────────────────────────────────────────────
/**
* Load server settings from GET /api/settings and cache in _serverSettings.
* Always resolves — errors are logged as warnings.
* @returns {Promise<object>}
*/
async function loadServerSettings() {
try {
const res = await api('GET', '/api/settings');
_serverSettings = await res.json();
} catch (err) {
console.warn('[loadServerSettings] failed:', err);
if (!_serverSettings) _serverSettings = {};
}
return _serverSettings;
}
/**
* Send a PATCH to /api/settings with a single key/value update.
* Shows a toast on success or failure.
* @param {string} key
* @param {*} value
* @returns {Promise<void>}
*/
async function patchServerSetting(key, value) {
try {
await api('PATCH', '/api/settings', { [key]: value });
_serverSettings = Object.assign({}, _serverSettings, { [key]: value });
showToast('Setting saved');
} catch (err) {
showToast('Failed to save setting');
console.warn('[patchServerSetting] failed:', err);
}
}
// ─── Settings dialog ──────────────────────────────────────────────────────────
/**
@@ -1000,6 +1037,61 @@ function openSettings() {
if (hoverDelayEl) hoverDelayEl.value = String(settings.hoverPreviewDelay);
const gridColumnsEl = $('setting-grid-columns');
if (gridColumnsEl) gridColumnsEl.value = String(settings.gridColumns);
// Populate Sessions tab from server settings
loadServerSettings().then(function(ss) {
// Default session dropdown
const defaultSessionEl = $('setting-default-session');
if (defaultSessionEl) {
// Rebuild options from current sessions
defaultSessionEl.innerHTML = '<option value="">(none)</option>';
(_currentSessions || []).forEach(function(s) {
const opt = document.createElement('option');
opt.value = s.name || '';
opt.textContent = s.name || '';
if (ss && ss.default_session === s.name) opt.selected = true;
defaultSessionEl.appendChild(opt);
});
}
// Sort order
const sortOrderEl = $('setting-sort-order');
if (sortOrderEl && ss && ss.sort_order) {
sortOrderEl.value = ss.sort_order;
}
// Hidden sessions checkboxes
const hiddenSessionsEl = $('setting-hidden-sessions');
if (hiddenSessionsEl) {
hiddenSessionsEl.innerHTML = '';
const hiddenList = (ss && ss.hidden_sessions) || [];
(_currentSessions || []).forEach(function(s) {
const name = s.name || '';
const item = document.createElement('label');
item.className = 'settings-checkbox-item';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'settings-checkbox';
cb.value = name;
cb.checked = hiddenList.includes(name);
item.appendChild(cb);
item.appendChild(document.createTextNode(' ' + name));
hiddenSessionsEl.appendChild(item);
});
}
// Window size largest
const windowSizeEl = $('setting-window-size-largest');
if (windowSizeEl) {
windowSizeEl.checked = !!(ss && ss.window_size_largest);
}
// Auto-open
const autoOpenEl = $('setting-auto-open');
if (autoOpenEl) {
autoOpenEl.checked = ss && ss.auto_open !== undefined ? !!ss.auto_open : true;
}
});
}
/**
@@ -1205,6 +1297,38 @@ function bindStaticEventListeners() {
on($('setting-font-size'), 'change', onDisplaySettingChange);
on($('setting-hover-delay'), 'change', onDisplaySettingChange);
on($('setting-grid-columns'), 'change', onDisplaySettingChange);
// Sessions settings — bind change events for server-side persistence
on($('setting-default-session'), 'change', function() {
var el = $('setting-default-session');
if (el) patchServerSetting('default_session', el.value);
});
on($('setting-sort-order'), 'change', function() {
var el = $('setting-sort-order');
if (el) patchServerSetting('sort_order', el.value);
});
on($('setting-window-size-largest'), 'change', function() {
var el = $('setting-window-size-largest');
if (el) patchServerSetting('window_size_largest', el.checked);
});
on($('setting-auto-open'), 'change', function() {
var el = $('setting-auto-open');
if (el) patchServerSetting('auto_open', el.checked);
});
// Hidden sessions — delegated handler on container (checkboxes are dynamic)
var hiddenSessionsContainer = $('setting-hidden-sessions');
if (hiddenSessionsContainer) {
hiddenSessionsContainer.addEventListener('change', function(e) {
var cb = e.target.closest('input[type="checkbox"]');
if (!cb) return;
var hidden = [];
hiddenSessionsContainer.querySelectorAll('input[type="checkbox"]').forEach(function(c) {
if (c.checked) hidden.push(c.value);
});
patchServerSetting('hidden_sessions', hidden);
});
}
}
// ─── Test-only helpers ────────────────────────────────────────────────────────
@@ -1291,6 +1415,9 @@ if (typeof module !== 'undefined' && module.exports) {
openSettings,
closeSettings,
switchSettingsTab,
// Server settings
loadServerSettings,
patchServerSetting,
// Test-only helpers
_setCurrentSessions,
_setViewMode,
+28 -1
View File
@@ -111,7 +111,34 @@
</select>
</div>
</div>
<div class="settings-panel hidden" data-tab="sessions"></div>
<div class="settings-panel hidden" data-tab="sessions">
<div class="settings-field">
<label class="settings-label" for="setting-default-session">Default Session</label>
<select id="setting-default-session" class="settings-select">
<option value="">(none)</option>
</select>
</div>
<div class="settings-field">
<label class="settings-label" for="setting-sort-order">Sort Order</label>
<select id="setting-sort-order" class="settings-select">
<option value="manual">Manual</option>
<option value="alphabetical">Alphabetical</option>
<option value="recent">Recent</option>
</select>
</div>
<div class="settings-field settings-field--column">
<label class="settings-label">Hidden Sessions</label>
<div id="setting-hidden-sessions" class="settings-checkbox-list"></div>
</div>
<div class="settings-field">
<label class="settings-label" for="setting-window-size-largest">Auto-set window size (largest)</label>
<input type="checkbox" id="setting-window-size-largest" class="settings-checkbox" />
</div>
<div class="settings-field">
<label class="settings-label" for="setting-auto-open">Auto-open created sessions</label>
<input type="checkbox" id="setting-auto-open" class="settings-checkbox" checked />
</div>
</div>
<div class="settings-panel hidden" data-tab="notifications"></div>
<div class="settings-panel hidden" data-tab="new-session"></div>
</div>
+32
View File
@@ -1059,6 +1059,38 @@ body {
}
}
/* ============================================================
Sessions tab controls
============================================================ */
.settings-field--column {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.settings-checkbox-list {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.settings-checkbox-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text);
}
.settings-checkbox {
accent-color: var(--accent);
width: 16px;
height: 16px;
cursor: pointer;
}
/* ============================================================
Responsive overlay sidebar at <960px
============================================================ */
+47
View File
@@ -851,3 +851,50 @@ def test_css_settings_mobile_tabs_horizontal():
tabs_block = _extract_rule_block(media_block, ".settings-tabs {")
assert "flex-direction: row" in tabs_block, ".settings-tabs must become horizontal on mobile"
assert "overflow-x: auto" in tabs_block, ".settings-tabs must scroll horizontally on mobile"
# ─── Sessions tab CSS (task-1-sessions-tab) ───────────────────────────────────
def test_css_settings_field_column() -> None:
""".settings-field--column must set flex-direction: column."""
css = read_css()
assert ".settings-field--column" in css, (
".settings-field--column class must be defined in style.css"
)
# Find the rule and check flex-direction
import re
match = re.search(
r"\.settings-field--column\s*\{([^}]*)\}",
css,
re.DOTALL,
)
assert match, ".settings-field--column rule not found"
body = match.group(1)
assert "flex-direction" in body and "column" in body, (
".settings-field--column must set flex-direction: column"
)
def test_css_settings_checkbox_list() -> None:
""".settings-checkbox-list class must be defined."""
css = read_css()
assert ".settings-checkbox-list" in css, (
".settings-checkbox-list class must be defined in style.css"
)
def test_css_settings_checkbox_item() -> None:
""".settings-checkbox-item class must be defined."""
css = read_css()
assert ".settings-checkbox-item" in css, (
".settings-checkbox-item class must be defined in style.css"
)
def test_css_settings_checkbox() -> None:
""".settings-checkbox class must be defined."""
css = read_css()
assert ".settings-checkbox" in css, (
".settings-checkbox class must be defined in style.css"
)
+85
View File
@@ -577,3 +577,88 @@ def test_html_settings_tab_panel_data_tab_alignment() -> None:
f"Tab buttons {missing_panels} have no matching settings-panel[data-tab=...]. "
f"Panel data-tab values found: {panel_values}"
)
# ============================================================
# Sessions tab (task-1-sessions-tab)
# ============================================================
def test_html_sessions_panel_has_default_session_select() -> None:
"""Sessions panel must contain a #setting-default-session select."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"})
assert sessions_panel is not None, "Missing sessions settings-panel"
el = sessions_panel.find(id="setting-default-session")
assert el is not None, "Missing #setting-default-session inside sessions panel"
assert el.name == "select", (
f"#setting-default-session must be a <select>, got: {el.name}"
)
def test_html_sessions_panel_has_sort_order_select() -> None:
"""Sessions panel must contain a #setting-sort-order select with manual/alphabetical/recent options."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"})
assert sessions_panel is not None, "Missing sessions settings-panel"
el = sessions_panel.find(id="setting-sort-order")
assert el is not None, "Missing #setting-sort-order inside sessions panel"
assert el.name == "select", (
f"#setting-sort-order must be a <select>, got: {el.name}"
)
options = el.find_all("option")
values = [o.get("value") for o in options]
for v in ("manual", "alphabetical", "recent"):
assert v in values, f"#setting-sort-order missing option value='{v}'"
def test_html_sessions_panel_has_hidden_sessions_container() -> None:
"""Sessions panel must contain a #setting-hidden-sessions container for checkboxes."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"})
assert sessions_panel is not None, "Missing sessions settings-panel"
el = sessions_panel.find(id="setting-hidden-sessions")
assert el is not None, "Missing #setting-hidden-sessions inside sessions panel"
def test_html_sessions_panel_has_window_size_largest_checkbox() -> None:
"""Sessions panel must contain a #setting-window-size-largest checkbox."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"})
assert sessions_panel is not None, "Missing sessions settings-panel"
el = sessions_panel.find(id="setting-window-size-largest")
assert el is not None, "Missing #setting-window-size-largest inside sessions panel"
assert el.name == "input", (
f"#setting-window-size-largest must be an <input>, got: {el.name}"
)
assert el.get("type") == "checkbox", (
f"#setting-window-size-largest must be type='checkbox', got: {el.get('type')}"
)
def test_html_sessions_panel_has_auto_open_checkbox_default_checked() -> None:
"""Sessions panel must contain a #setting-auto-open checkbox with default checked."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"})
assert sessions_panel is not None, "Missing sessions settings-panel"
el = sessions_panel.find(id="setting-auto-open")
assert el is not None, "Missing #setting-auto-open inside sessions panel"
assert el.name == "input", (
f"#setting-auto-open must be an <input>, got: {el.name}"
)
assert el.get("type") == "checkbox", (
f"#setting-auto-open must be type='checkbox', got: {el.get('type')}"
)
assert el.get("checked") is not None, (
"#setting-auto-open must be checked by default"
)
+181
View File
@@ -1116,3 +1116,184 @@ def test_exports_on_display_setting_change() -> None:
assert "onDisplaySettingChange" in exports, (
"module.exports must export onDisplaySettingChange"
)
# ─── Server settings functions (task-1-sessions-tab) ─────────────────────────
def test_load_server_settings_function_exists() -> None:
"""loadServerSettings function must exist."""
assert "function loadServerSettings" in _JS, (
"loadServerSettings must be defined in app.js"
)
def test_load_server_settings_fetches_api_settings() -> None:
"""loadServerSettings must fetch GET /api/settings."""
match = re.search(
r"async function loadServerSettings\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "loadServerSettings function not found"
body = match.group(1)
assert "/api/settings" in body, "loadServerSettings must fetch /api/settings"
assert "GET" in body, "loadServerSettings must use GET method"
def test_load_server_settings_caches_in_server_settings() -> None:
"""loadServerSettings must cache result in _serverSettings."""
assert "_serverSettings" in _JS, "_serverSettings variable must exist in app.js"
match = re.search(
r"async function loadServerSettings\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "loadServerSettings function not found"
body = match.group(1)
assert "_serverSettings" in body, (
"loadServerSettings must cache the result in _serverSettings"
)
def test_patch_server_setting_function_exists() -> None:
"""patchServerSetting function must exist."""
assert "function patchServerSetting" in _JS, (
"patchServerSetting must be defined in app.js"
)
def test_patch_server_setting_sends_patch_request() -> None:
"""patchServerSetting must send PATCH to /api/settings."""
match = re.search(
r"async function patchServerSetting\s*\(.*?\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "patchServerSetting function not found"
body = match.group(1)
assert "/api/settings" in body, "patchServerSetting must send to /api/settings"
assert "PATCH" in body, "patchServerSetting must use PATCH method"
def test_patch_server_setting_shows_toast() -> None:
"""patchServerSetting must call showToast."""
match = re.search(
r"async function patchServerSetting\s*\(.*?\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "patchServerSetting function not found"
body = match.group(1)
assert "showToast" in body, "patchServerSetting must call showToast"
def test_open_settings_calls_load_server_settings() -> None:
"""openSettings must call loadServerSettings to populate sessions tab."""
match = re.search(
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "openSettings function not found"
body = match.group(1)
assert "loadServerSettings" in body, (
"openSettings must call loadServerSettings"
)
def test_bind_static_event_listeners_binds_default_session_change() -> None:
"""bindStaticEventListeners must bind change on setting-default-session."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "setting-default-session" in body, (
"bindStaticEventListeners must bind setting-default-session change"
)
def test_bind_static_event_listeners_binds_sort_order_change() -> None:
"""bindStaticEventListeners must bind change on setting-sort-order."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "setting-sort-order" in body, (
"bindStaticEventListeners must bind setting-sort-order change"
)
def test_bind_static_event_listeners_binds_window_size_largest_change() -> None:
"""bindStaticEventListeners must bind change on setting-window-size-largest."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "setting-window-size-largest" in body, (
"bindStaticEventListeners must bind setting-window-size-largest change"
)
def test_bind_static_event_listeners_binds_auto_open_change() -> None:
"""bindStaticEventListeners must bind change on setting-auto-open."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "setting-auto-open" in body, (
"bindStaticEventListeners must bind setting-auto-open change"
)
def test_bind_static_event_listeners_uses_delegated_handler_for_hidden_sessions() -> None:
"""bindStaticEventListeners must use delegated change handler on #setting-hidden-sessions."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "setting-hidden-sessions" in body, (
"bindStaticEventListeners must use delegated handler on setting-hidden-sessions"
)
def test_exports_load_server_settings() -> None:
"""module.exports must export loadServerSettings."""
match = re.search(
r"module\.exports\s*=\s*\{(.*?)\};",
_JS,
re.DOTALL,
)
assert match, "module.exports block not found"
exports = match.group(1)
assert "loadServerSettings" in exports, (
"module.exports must export loadServerSettings"
)
def test_exports_patch_server_setting() -> None:
"""module.exports must export patchServerSetting."""
match = re.search(
r"module\.exports\s*=\s*\{(.*?)\};",
_JS,
re.DOTALL,
)
assert match, "module.exports block not found"
exports = match.group(1)
assert "patchServerSetting" in exports, (
"module.exports must export patchServerSetting"
)