feat: add Notifications settings tab
This commit is contained in:
@@ -1038,6 +1038,29 @@ function openSettings() {
|
||||
const gridColumnsEl = $('setting-grid-columns');
|
||||
if (gridColumnsEl) gridColumnsEl.value = String(settings.gridColumns);
|
||||
|
||||
// Populate Notifications tab from display settings
|
||||
const bellSoundEl = $('setting-bell-sound');
|
||||
if (bellSoundEl) bellSoundEl.checked = !!settings.bellSound;
|
||||
|
||||
// Update notification permission status text/button
|
||||
const statusEl = $('notification-status-text');
|
||||
const reqBtn = $('notification-request-btn');
|
||||
if (statusEl && reqBtn) {
|
||||
if (typeof Notification === 'undefined') {
|
||||
statusEl.textContent = 'Not supported';
|
||||
reqBtn.disabled = true;
|
||||
} else if (Notification.permission === 'granted') {
|
||||
statusEl.textContent = 'Granted';
|
||||
reqBtn.disabled = true;
|
||||
} else if (Notification.permission === 'denied') {
|
||||
statusEl.textContent = 'Denied (check browser settings)';
|
||||
reqBtn.disabled = true;
|
||||
} else {
|
||||
statusEl.textContent = 'Not requested';
|
||||
reqBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate Sessions tab from server settings
|
||||
loadServerSettings().then(function(ss) {
|
||||
// Default session dropdown
|
||||
@@ -1329,6 +1352,41 @@ function bindStaticEventListeners() {
|
||||
patchServerSetting('hidden_sessions', hidden);
|
||||
});
|
||||
}
|
||||
|
||||
// Notifications settings — bell sound toggle persists to display settings localStorage
|
||||
on($('setting-bell-sound'), 'change', function() {
|
||||
var el = $('setting-bell-sound');
|
||||
if (!el) return;
|
||||
var ds = loadDisplaySettings();
|
||||
ds.bellSound = el.checked;
|
||||
saveDisplaySettings(ds);
|
||||
});
|
||||
|
||||
// Notifications settings — permission request button
|
||||
on($('notification-request-btn'), 'click', function() {
|
||||
if (typeof Notification === 'undefined') return;
|
||||
Notification.requestPermission().then(function(permission) {
|
||||
_notificationPermission = permission;
|
||||
var ds = loadDisplaySettings();
|
||||
ds.notificationPermission = permission;
|
||||
saveDisplaySettings(ds);
|
||||
// Update UI state
|
||||
var statusEl = $('notification-status-text');
|
||||
var reqBtn = $('notification-request-btn');
|
||||
if (statusEl && reqBtn) {
|
||||
if (permission === 'granted') {
|
||||
statusEl.textContent = 'Granted';
|
||||
reqBtn.disabled = true;
|
||||
} else if (permission === 'denied') {
|
||||
statusEl.textContent = 'Denied (check browser settings)';
|
||||
reqBtn.disabled = true;
|
||||
} else {
|
||||
statusEl.textContent = 'Not requested';
|
||||
reqBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Test-only helpers ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -139,7 +139,19 @@
|
||||
<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="notifications">
|
||||
<div class="settings-field">
|
||||
<label class="settings-label" for="setting-bell-sound">Bell Sound</label>
|
||||
<input type="checkbox" id="setting-bell-sound" class="settings-checkbox" />
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label class="settings-label">Desktop Notifications</label>
|
||||
<div class="settings-notification-status">
|
||||
<span id="notification-status-text" class="settings-status-text">Not requested</span>
|
||||
<button id="notification-request-btn" class="settings-action-btn">Request Permission</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-panel hidden" data-tab="new-session"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1091,6 +1091,41 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Notifications tab controls
|
||||
============================================================ */
|
||||
|
||||
.settings-notification-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-status-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-action-btn {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-action-btn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.settings-action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Responsive overlay sidebar at <960px
|
||||
============================================================ */
|
||||
|
||||
@@ -898,3 +898,116 @@ def test_css_settings_checkbox() -> None:
|
||||
assert ".settings-checkbox" in css, (
|
||||
".settings-checkbox class must be defined in style.css"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Notifications tab CSS (task-2-notifications-tab)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_css_settings_notification_status_exists() -> None:
|
||||
""".settings-notification-status must exist with flex column, align-items flex-end."""
|
||||
import re
|
||||
css = read_css()
|
||||
assert ".settings-notification-status" in css, (
|
||||
".settings-notification-status class must be defined in style.css"
|
||||
)
|
||||
match = re.search(
|
||||
r"\.settings-notification-status\s*\{([^}]*)\}",
|
||||
css,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, ".settings-notification-status rule not found"
|
||||
body = match.group(1)
|
||||
assert "flex-direction" in body and "column" in body, (
|
||||
".settings-notification-status must set flex-direction: column"
|
||||
)
|
||||
assert "align-items" in body and "flex-end" in body, (
|
||||
".settings-notification-status must set align-items: flex-end"
|
||||
)
|
||||
|
||||
|
||||
def test_css_settings_status_text_exists() -> None:
|
||||
""".settings-status-text must exist with 12px font-size and text-muted color."""
|
||||
import re
|
||||
css = read_css()
|
||||
assert ".settings-status-text" in css, (
|
||||
".settings-status-text class must be defined in style.css"
|
||||
)
|
||||
match = re.search(
|
||||
r"\.settings-status-text\s*\{([^}]*)\}",
|
||||
css,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, ".settings-status-text rule not found"
|
||||
body = match.group(1)
|
||||
assert "font-size: 12px" in body or "font-size:12px" in body, (
|
||||
".settings-status-text must set font-size: 12px"
|
||||
)
|
||||
# Must use text-muted color (either via var(--text-muted) or inline)
|
||||
assert "var(--text-muted)" in body or "text-muted" in body, (
|
||||
".settings-status-text must use var(--text-muted) color"
|
||||
)
|
||||
|
||||
|
||||
def test_css_settings_action_btn_exists() -> None:
|
||||
""".settings-action-btn must exist with background, border, 12px font-size."""
|
||||
import re
|
||||
css = read_css()
|
||||
assert ".settings-action-btn" in css, (
|
||||
".settings-action-btn class must be defined in style.css"
|
||||
)
|
||||
match = re.search(
|
||||
r"\.settings-action-btn\s*\{([^}]*)\}",
|
||||
css,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, ".settings-action-btn rule not found"
|
||||
body = match.group(1)
|
||||
assert "font-size: 12px" in body or "font-size:12px" in body, (
|
||||
".settings-action-btn must set font-size: 12px"
|
||||
)
|
||||
assert "border" in body, (
|
||||
".settings-action-btn must have border property"
|
||||
)
|
||||
assert "background" in body, (
|
||||
".settings-action-btn must have background property"
|
||||
)
|
||||
|
||||
|
||||
def test_css_settings_action_btn_hover_exists() -> None:
|
||||
""".settings-action-btn:hover must exist with border-color accent."""
|
||||
import re
|
||||
css = read_css()
|
||||
assert ".settings-action-btn:hover" in css, (
|
||||
".settings-action-btn:hover must be defined in style.css"
|
||||
)
|
||||
match = re.search(
|
||||
r"\.settings-action-btn:hover\s*\{([^}]*)\}",
|
||||
css,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, ".settings-action-btn:hover rule not found"
|
||||
body = match.group(1)
|
||||
assert "border-color" in body and "var(--accent)" in body, (
|
||||
".settings-action-btn:hover must set border-color: var(--accent)"
|
||||
)
|
||||
|
||||
|
||||
def test_css_settings_action_btn_disabled_opacity() -> None:
|
||||
""".settings-action-btn:disabled must have opacity 0.5."""
|
||||
import re
|
||||
css = read_css()
|
||||
assert ".settings-action-btn:disabled" in css, (
|
||||
".settings-action-btn:disabled must be defined in style.css"
|
||||
)
|
||||
match = re.search(
|
||||
r"\.settings-action-btn:disabled\s*\{([^}]*)\}",
|
||||
css,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, ".settings-action-btn:disabled rule not found"
|
||||
body = match.group(1)
|
||||
assert "opacity: 0.5" in body or "opacity:0.5" in body, (
|
||||
".settings-action-btn:disabled must set opacity: 0.5"
|
||||
)
|
||||
|
||||
@@ -662,3 +662,59 @@ def test_html_sessions_panel_has_auto_open_checkbox_default_checked() -> None:
|
||||
assert el.get("checked") is not None, (
|
||||
"#setting-auto-open must be checked by default"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Notifications tab (task-2-notifications-tab)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_html_notifications_panel_has_bell_sound_checkbox() -> None:
|
||||
"""Notifications panel must contain a #setting-bell-sound checkbox."""
|
||||
soup = _SOUP
|
||||
dialog = soup.find(id="settings-dialog")
|
||||
assert dialog is not None, "Missing #settings-dialog"
|
||||
notif_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "notifications"})
|
||||
assert notif_panel is not None, "Missing notifications settings-panel"
|
||||
el = notif_panel.find(id="setting-bell-sound")
|
||||
assert el is not None, "Missing #setting-bell-sound inside notifications panel"
|
||||
assert el.name == "input", (
|
||||
f"#setting-bell-sound must be an <input>, got: {el.name}"
|
||||
)
|
||||
assert el.get("type") == "checkbox", (
|
||||
f"#setting-bell-sound must be type='checkbox', got: {el.get('type')}"
|
||||
)
|
||||
classes = el.get("class") or []
|
||||
assert "settings-checkbox" in classes, (
|
||||
f"#setting-bell-sound must have class 'settings-checkbox', has: {classes}"
|
||||
)
|
||||
|
||||
|
||||
def test_html_notifications_panel_has_notification_status_text() -> None:
|
||||
"""Notifications panel must contain #notification-status-text with class settings-status-text."""
|
||||
soup = _SOUP
|
||||
dialog = soup.find(id="settings-dialog")
|
||||
assert dialog is not None, "Missing #settings-dialog"
|
||||
notif_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "notifications"})
|
||||
assert notif_panel is not None, "Missing notifications settings-panel"
|
||||
el = notif_panel.find(id="notification-status-text")
|
||||
assert el is not None, "Missing #notification-status-text inside notifications panel"
|
||||
classes = el.get("class") or []
|
||||
assert "settings-status-text" in classes, (
|
||||
f"#notification-status-text must have class 'settings-status-text', has: {classes}"
|
||||
)
|
||||
|
||||
|
||||
def test_html_notifications_panel_has_request_btn() -> None:
|
||||
"""Notifications panel must contain #notification-request-btn with class settings-action-btn."""
|
||||
soup = _SOUP
|
||||
dialog = soup.find(id="settings-dialog")
|
||||
assert dialog is not None, "Missing #settings-dialog"
|
||||
notif_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "notifications"})
|
||||
assert notif_panel is not None, "Missing notifications settings-panel"
|
||||
el = notif_panel.find(id="notification-request-btn")
|
||||
assert el is not None, "Missing #notification-request-btn inside notifications panel"
|
||||
classes = el.get("class") or []
|
||||
assert "settings-action-btn" in classes, (
|
||||
f"#notification-request-btn must have class 'settings-action-btn', has: {classes}"
|
||||
)
|
||||
|
||||
@@ -1297,3 +1297,110 @@ def test_exports_patch_server_setting() -> None:
|
||||
assert "patchServerSetting" in exports, (
|
||||
"module.exports must export patchServerSetting"
|
||||
)
|
||||
|
||||
|
||||
# ─── Notifications tab (task-2-notifications-tab) ─────────────────────────────
|
||||
|
||||
def test_open_settings_populates_bell_sound() -> None:
|
||||
"""openSettings must set setting-bell-sound checkbox from loadDisplaySettings().bellSound."""
|
||||
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 "setting-bell-sound" in body, (
|
||||
"openSettings must reference setting-bell-sound to set bell sound checkbox"
|
||||
)
|
||||
assert "bellSound" in body, (
|
||||
"openSettings must read bellSound from display settings"
|
||||
)
|
||||
|
||||
|
||||
def test_open_settings_updates_notification_status_text() -> None:
|
||||
"""openSettings must update notification permission status text and button."""
|
||||
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 "notification-status-text" in body, (
|
||||
"openSettings must reference notification-status-text to update permission status"
|
||||
)
|
||||
assert "notification-request-btn" in body, (
|
||||
"openSettings must reference notification-request-btn to update button state"
|
||||
)
|
||||
|
||||
|
||||
def test_open_settings_checks_notification_permission() -> None:
|
||||
"""openSettings must check Notification.permission to update UI."""
|
||||
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 "Notification" in body or "_notificationPermission" in body, (
|
||||
"openSettings must check Notification.permission or _notificationPermission"
|
||||
)
|
||||
|
||||
|
||||
def test_bind_static_event_listeners_binds_bell_sound_change() -> None:
|
||||
"""bindStaticEventListeners must bind change on setting-bell-sound to save to localStorage."""
|
||||
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-bell-sound" in body, (
|
||||
"bindStaticEventListeners must bind setting-bell-sound change event"
|
||||
)
|
||||
|
||||
|
||||
def test_bind_static_event_listeners_bell_sound_saves_to_display_settings() -> None:
|
||||
"""bindStaticEventListeners bell sound change handler must save to display settings."""
|
||||
match = re.search(
|
||||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "bindStaticEventListeners function not found"
|
||||
body = match.group(1)
|
||||
# The handler needs to reference saveDisplaySettings and bellSound
|
||||
assert "saveDisplaySettings" in body or "bellSound" in body, (
|
||||
"bindStaticEventListeners bell sound change handler must save to display settings via saveDisplaySettings or bellSound"
|
||||
)
|
||||
|
||||
|
||||
def test_bind_static_event_listeners_binds_permission_btn() -> None:
|
||||
"""bindStaticEventListeners must bind click on notification-request-btn."""
|
||||
match = re.search(
|
||||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "bindStaticEventListeners function not found"
|
||||
body = match.group(1)
|
||||
assert "notification-request-btn" in body, (
|
||||
"bindStaticEventListeners must bind notification-request-btn click event"
|
||||
)
|
||||
|
||||
|
||||
def test_bind_static_event_listeners_permission_btn_calls_request_permission() -> None:
|
||||
"""bindStaticEventListeners permission button handler must call Notification.requestPermission()."""
|
||||
match = re.search(
|
||||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||||
_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "bindStaticEventListeners function not found"
|
||||
body = match.group(1)
|
||||
assert "requestPermission" in body, (
|
||||
"bindStaticEventListeners permission button must call Notification.requestPermission()"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user