diff --git a/src/app/country-intel.ts b/src/app/country-intel.ts index 77349c9cc..6d5087589 100644 --- a/src/app/country-intel.ts +++ b/src/app/country-intel.ts @@ -42,6 +42,7 @@ import { getNearbyInfrastructure } from '@/services/related-assets'; import { toFlagEmoji } from '@/utils/country-flag'; import { buildDependencyGraph } from '@/services/infrastructure-cascade'; import { getActiveFrameworkForPanel, subscribeFrameworkChange } from '@/services/analysis-framework-store'; +import { showToast } from '@/utils/toast'; type IntlDisplayNamesCtor = new ( locales: string | string[], @@ -976,7 +977,7 @@ export class CountryIntelManager implements AppModule { openCountryStory(code: string, name: string): void { if (!dataFreshness.hasSufficientData() || this.ctx.latestClusters.length === 0) { - this.showToast('Data still loading — try again in a moment'); + showToast('Data still loading — try again in a moment'); return; } const posturePanel = this.ctx.panels['strategic-posture'] as StrategicPosturePanel | undefined; @@ -993,16 +994,6 @@ export class CountryIntelManager implements AppModule { openStoryModal(data); } - showToast(msg: string): void { - document.querySelector('.toast-notification')?.remove(); - const el = document.createElement('div'); - el.className = 'toast-notification'; - el.textContent = msg; - document.body.appendChild(el); - requestAnimationFrame(() => el.classList.add('visible')); - setTimeout(() => { el.classList.remove('visible'); setTimeout(() => el.remove(), 300); }, 3000); - } - private getCountryStrikes(code: string, hasGeoShape: boolean): typeof this.ctx.intelligenceCache.iranEvents & object { if (!this.ctx.intelligenceCache.iranEvents) return []; const seen = new Set(); diff --git a/src/app/event-handlers.ts b/src/app/event-handlers.ts index 5d165b3a5..6669882b1 100644 --- a/src/app/event-handlers.ts +++ b/src/app/event-handlers.ts @@ -66,6 +66,7 @@ import { AuthHeaderWidget } from '@/components/AuthHeaderWidget'; import { t } from '@/services/i18n'; import { TvModeController } from '@/services/tv-mode'; import { getAuthState, subscribeAuthState } from '@/services/auth-state'; +import { showToast } from '@/utils/toast'; export interface EventHandlerCallbacks { updateSearchIndex: () => void; @@ -1027,7 +1028,7 @@ export class EventHandlerManager implements AppModule { const allSources = this.getAllSourceNames(); const currentlyEnabled = allSources.filter(n => !this.ctx.disabledSources.has(n)).length; if (currentlyEnabled + 1 > FREE_MAX_SOURCES) { - this.showToast(t('modals.settingsWindow.freeSourceLimit', { max: String(FREE_MAX_SOURCES) })); + showToast(t('modals.settingsWindow.freeSourceLimit', { max: String(FREE_MAX_SOURCES) })); return; } } @@ -1044,7 +1045,7 @@ export class EventHandlerManager implements AppModule { const currentlyEnabled = allSources.filter(n => !this.ctx.disabledSources.has(n)).length; const wouldEnable = names.filter(n => this.ctx.disabledSources.has(n) && allSources.includes(n)).length; if (currentlyEnabled + wouldEnable > FREE_MAX_SOURCES) { - this.showToast(t('modals.settingsWindow.freeSourceLimit', { max: String(FREE_MAX_SOURCES) })); + showToast(t('modals.settingsWindow.freeSourceLimit', { max: String(FREE_MAX_SOURCES) })); return; } } @@ -1243,16 +1244,6 @@ export class EventHandlerManager implements AppModule { } } - showToast(msg: string): void { - document.querySelector('.toast-notification')?.remove(); - const el = document.createElement('div'); - el.className = 'toast-notification'; - el.textContent = msg; - document.body.appendChild(el); - requestAnimationFrame(() => el.classList.add('visible')); - setTimeout(() => { el.classList.remove('visible'); setTimeout(() => el.remove(), 300); }, 3000); - } - shouldShowIntelligenceNotifications(): boolean { return !this.ctx.isMobile && !!this.ctx.findingsBadge?.isPopupEnabled(); } diff --git a/src/components/UnifiedSettings.ts b/src/components/UnifiedSettings.ts index 8fde52310..b24c55ede 100644 --- a/src/components/UnifiedSettings.ts +++ b/src/components/UnifiedSettings.ts @@ -5,20 +5,11 @@ import { SITE_VARIANT } from '@/config/variant'; import { t } from '@/services/i18n'; import type { MapProvider } from '@/config/basemap'; import { escapeHtml } from '@/utils/sanitize'; +import { showToast } from '@/utils/toast'; import type { PanelConfig } from '@/types'; import { renderPreferences } from '@/services/preferences-content'; import { track } from '@/services/analytics'; -function showToast(msg: string): void { - document.querySelector('.toast-notification')?.remove(); - const el = document.createElement('div'); - el.className = 'toast-notification'; - el.textContent = msg; - document.body.appendChild(el); - requestAnimationFrame(() => el.classList.add('visible')); - setTimeout(() => { el.classList.remove('visible'); setTimeout(() => el.remove(), 300); }, 4000); -} - const GEAR_SVG = ``; export interface UnifiedSettingsConfig { @@ -217,6 +208,7 @@ export class UnifiedSettings { const prefs = renderPreferences({ isDesktopApp: this.config.isDesktopApp, onMapProviderChange: this.config.onMapProviderChange, + onSettingSaved: () => showToast(t('modals.settingsWindow.saved')), }); this.overlay.innerHTML = ` diff --git a/src/services/preferences-content.ts b/src/services/preferences-content.ts index 922c4d298..441c2031a 100644 --- a/src/services/preferences-content.ts +++ b/src/services/preferences-content.ts @@ -23,6 +23,7 @@ const DESKTOP_RELEASES_URL = 'https://github.com/koala73/worldmonitor/releases'; export interface PreferencesHost { isDesktopApp: boolean; onMapProviderChange?: (provider: MapProvider) => void; + onSettingSaved?: () => void; } export interface PreferencesResult { @@ -76,6 +77,88 @@ function updateAiStatus(container: HTMLElement): void { } } +function handleSettingsImport(target: HTMLInputElement, container: HTMLElement): boolean { + if (target.id !== 'usImportInput') return false; + + const file = target.files?.[0]; + if (!file) return true; + + importSettings(file).then((result: ImportResult) => { + showToast(container, t('components.settings.importSuccess', { count: String(result.keysImported) }), true); + }).catch(() => { + showToast(container, t('components.settings.importFailed'), false); + }); + target.value = ''; + return true; +} + +function handlePreferenceChange(target: HTMLInputElement, container: HTMLElement, host: PreferencesHost): boolean { + if (target.id === 'us-stream-quality') { + setStreamQuality(target.value as StreamQuality); + return true; + } + if (target.id === 'us-globe-visual-preset') { + setGlobeVisualPreset(target.value as GlobeVisualPreset); + return true; + } + if (target.id === 'us-theme') { + setThemePreference(target.value as ThemePreference); + return true; + } + if (target.id === 'us-font-family') { + setFontFamily(target.value as FontFamily); + return true; + } + if (target.id === 'us-map-provider') { + const provider = target.value as MapProvider; + setMapProvider(provider); + renderMapThemeDropdown(container, provider); + host.onMapProviderChange?.(provider); + window.dispatchEvent(new CustomEvent('map-theme-changed')); + return true; + } + if (target.id === 'us-map-theme') { + const provider = getMapProvider(); + setMapTheme(provider, target.value); + window.dispatchEvent(new CustomEvent('map-theme-changed')); + return true; + } + if (target.id === 'us-live-streams-always-on') { + setLiveStreamsAlwaysOn(target.checked); + return true; + } + if (target.id === 'us-language') { + trackLanguageChange(target.value); + void changeLanguage(target.value); + return true; + } + if (target.id === 'us-cloud') { + setAiFlowSetting('cloudLlm', target.checked); + updateAiStatus(container); + return true; + } + if (target.id === 'us-browser') { + setAiFlowSetting('browserModel', target.checked); + const warn = container.querySelector('.ai-flow-toggle-warn') as HTMLElement; + if (warn) warn.style.display = target.checked ? 'block' : 'none'; + updateAiStatus(container); + return true; + } + if (target.id === 'us-map-flash') { + setAiFlowSetting('mapNewsFlash', target.checked); + return true; + } + if (target.id === 'us-headline-memory') { + setAiFlowSetting('headlineMemory', target.checked); + return true; + } + if (target.id === 'us-badge-anim') { + setAiFlowSetting('badgeAnimation', target.checked); + return true; + } + return false; +} + export function renderPreferences(host: PreferencesHost): PreferencesResult { const settings = getAiFlowSettings(); const currentLang = getCurrentLanguage(); @@ -344,72 +427,9 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { container.addEventListener('change', (e) => { const target = e.target as HTMLInputElement; - if (target.id === 'usImportInput') { - const file = target.files?.[0]; - if (!file) return; - importSettings(file).then((result: ImportResult) => { - showToast(container, t('components.settings.importSuccess', { count: String(result.keysImported) }), true); - }).catch(() => { - showToast(container, t('components.settings.importFailed'), false); - }); - target.value = ''; - return; - } - - if (target.id === 'us-stream-quality') { - setStreamQuality(target.value as StreamQuality); - return; - } - if (target.id === 'us-globe-visual-preset') { - setGlobeVisualPreset(target.value as GlobeVisualPreset); - return; - } - if (target.id === 'us-theme') { - setThemePreference(target.value as ThemePreference); - return; - } - if (target.id === 'us-font-family') { - setFontFamily(target.value as FontFamily); - return; - } - if (target.id === 'us-map-provider') { - const provider = target.value as MapProvider; - setMapProvider(provider); - renderMapThemeDropdown(container, provider); - host.onMapProviderChange?.(provider); - window.dispatchEvent(new CustomEvent('map-theme-changed')); - return; - } - if (target.id === 'us-map-theme') { - const provider = getMapProvider(); - setMapTheme(provider, target.value); - window.dispatchEvent(new CustomEvent('map-theme-changed')); - return; - } - if (target.id === 'us-live-streams-always-on') { - setLiveStreamsAlwaysOn(target.checked); - return; - } - if (target.id === 'us-language') { - trackLanguageChange(target.value); - void changeLanguage(target.value); - return; - } - if (target.id === 'us-cloud') { - setAiFlowSetting('cloudLlm', target.checked); - updateAiStatus(container); - } else if (target.id === 'us-browser') { - setAiFlowSetting('browserModel', target.checked); - const warn = container.querySelector('.ai-flow-toggle-warn') as HTMLElement; - if (warn) warn.style.display = target.checked ? 'block' : 'none'; - updateAiStatus(container); - } else if (target.id === 'us-map-flash') { - setAiFlowSetting('mapNewsFlash', target.checked); - } else if (target.id === 'us-headline-memory') { - setAiFlowSetting('headlineMemory', target.checked); - } else if (target.id === 'us-badge-anim') { - setAiFlowSetting('badgeAnimation', target.checked); - } + if (handleSettingsImport(target, container)) return; + if (!handlePreferenceChange(target, container, host)) return; + host.onSettingSaved?.(); }, { signal }); container.addEventListener('click', (e) => { diff --git a/src/utils/toast.ts b/src/utils/toast.ts new file mode 100644 index 000000000..ca7342f9a --- /dev/null +++ b/src/utils/toast.ts @@ -0,0 +1,15 @@ +export function showToast(message: string): void { + document.querySelector('.toast-notification')?.remove(); + + const toast = document.createElement('div'); + toast.className = 'toast-notification'; + toast.setAttribute('role', 'status'); + toast.textContent = message; + + document.body.appendChild(toast); + requestAnimationFrame(() => toast.classList.add('visible')); + setTimeout(() => { + toast.classList.remove('visible'); + setTimeout(() => toast.remove(), 300); + }, 3000); +} diff --git a/tests/settings-save-feedback.test.mjs b/tests/settings-save-feedback.test.mjs new file mode 100644 index 000000000..081fe10a5 --- /dev/null +++ b/tests/settings-save-feedback.test.mjs @@ -0,0 +1,62 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); + +function src(path) { + return readFileSync(join(root, path), 'utf-8'); +} + +function extractMethodBody(source, methodName) { + const signature = `${methodName}(): void {`; + const start = source.indexOf(signature); + assert.notEqual(start, -1, `${methodName}() not found`); + + let depth = 1; + let i = start + signature.length; + while (i < source.length && depth > 0) { + const char = source[i]; + if (char === '{') depth += 1; + if (char === '}') depth -= 1; + i += 1; + } + + assert.equal(depth, 0, `${methodName}() body did not terminate`); + return source.slice(start + signature.length, i - 1); +} + +describe('settings save feedback guardrails', () => { + const toastSrc = src('src/utils/toast.ts'); + const settingsSrc = src('src/components/UnifiedSettings.ts'); + const prefsSrc = src('src/services/preferences-content.ts'); + const handlersSrc = src('src/app/event-handlers.ts'); + const countryIntelSrc = src('src/app/country-intel.ts'); + + it('uses a shared body-level toast utility with role=status', () => { + assert.match(toastSrc, /export function showToast\(message: string\): void/); + assert.match(toastSrc, /toast\.setAttribute\('role', 'status'\)/); + assert.match(toastSrc, /document\.querySelector\('\.toast-notification'\)\?\.remove\(\)/); + }); + + it('shows saved feedback for Preferences through renderPreferences callback', () => { + assert.match(settingsSrc, /onSettingSaved:\s*\(\)\s*=>\s*showToast\(t\('modals\.settingsWindow\.saved'\)\)/); + assert.match(prefsSrc, /onSettingSaved\?: \(\) => void;/); + assert.match(prefsSrc, /host\.onSettingSaved\?\.\(\);/); + }); + + it('keeps Panels save on inline status only', () => { + const saveBody = extractMethodBody(settingsSrc, 'savePanelChanges'); + assert.doesNotMatch(saveBody, /showToast\(/); + }); + + it('removes duplicate global toast implementations from event handlers and country intel', () => { + assert.match(handlersSrc, /import \{ showToast \} from '@\/utils\/toast';/); + assert.match(countryIntelSrc, /import \{ showToast \} from '@\/utils\/toast';/); + assert.doesNotMatch(handlersSrc, /\n\s*showToast\(msg: string\): void \{/); + assert.doesNotMatch(countryIntelSrc, /\n\s*showToast\(msg: string\): void \{/); + }); +});