From e439bc913a81a4700596f21b638a49a060c917b1 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 4 Mar 2026 19:46:20 -0800 Subject: [PATCH] Reorganize the settings panes a bit --- frontend/src/components/SettingsModal.tsx | 65 +---- .../settings/SettingsConnectivitySection.tsx | 138 ---------- .../settings/SettingsDatabaseSection.tsx | 82 ------ .../settings/SettingsIdentitySection.tsx | 217 --------------- .../settings/SettingsLocalSection.tsx | 93 +++++++ .../settings/SettingsRadioSection.tsx | 257 +++++++++++++++++- .../components/settings/settingsConstants.ts | 13 +- frontend/src/test/appFavorites.test.tsx | 11 +- frontend/src/test/appStartupHash.test.tsx | 7 +- frontend/src/test/settingsModal.test.tsx | 47 ++-- tests/e2e/specs/radio-settings.spec.ts | 7 +- .../specs/reopen-last-conversation.spec.ts | 4 +- 12 files changed, 407 insertions(+), 534 deletions(-) delete mode 100644 frontend/src/components/settings/SettingsConnectivitySection.tsx delete mode 100644 frontend/src/components/settings/SettingsIdentitySection.tsx create mode 100644 frontend/src/components/settings/SettingsLocalSection.tsx diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 928a16d..dc94801 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -10,8 +10,7 @@ import type { LocalLabel } from '../utils/localLabel'; import { SETTINGS_SECTION_LABELS, type SettingsSection } from './settings/settingsConstants'; import { SettingsRadioSection } from './settings/SettingsRadioSection'; -import { SettingsIdentitySection } from './settings/SettingsIdentitySection'; -import { SettingsConnectivitySection } from './settings/SettingsConnectivitySection'; +import { SettingsLocalSection } from './settings/SettingsLocalSection'; import { SettingsMqttSection } from './settings/SettingsMqttSection'; import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection'; import { SettingsBotSection } from './settings/SettingsBotSection'; @@ -76,18 +75,14 @@ export function SettingsModal(props: SettingsModalProps) { const [isMobileLayout, setIsMobileLayout] = useState(getIsMobileLayout); const externalDesktopSidebarMode = externalSidebarNav && !isMobileLayout; - const [expandedSections, setExpandedSections] = useState>(() => { - const isMobile = getIsMobileLayout(); - return { - radio: !isMobile, - identity: false, - connectivity: false, - mqtt: false, - database: false, - bot: false, - statistics: false, - about: false, - }; + const [expandedSections, setExpandedSections] = useState>({ + radio: false, + local: false, + mqtt: false, + database: false, + bot: false, + statistics: false, + about: false, }); // Refresh settings from server when modal opens @@ -116,14 +111,6 @@ export function SettingsModal(props: SettingsModalProps) { return () => query.removeListener(onChange); }, []); - // On mobile with external sidebar nav, auto-expand the selected section - useEffect(() => { - if (!externalSidebarNav || !isMobileLayout || !desktopSection) return; - setExpandedSections((prev) => - prev[desktopSection] ? prev : { ...prev, [desktopSection]: true } - ); - }, [externalSidebarNav, isMobileLayout, desktopSection]); - const toggleSection = (section: SettingsSection) => { setExpandedSections((prev) => ({ ...prev, @@ -181,24 +168,8 @@ export function SettingsModal(props: SettingsModalProps) { {shouldRenderSection('radio') && (
{renderSectionHeader('radio')} - {isSectionVisible('radio') && ( + {isSectionVisible('radio') && appSettings && ( - )} -
- )} - - {shouldRenderSection('identity') && ( -
- {renderSectionHeader('identity')} - {isSectionVisible('identity') && appSettings && ( - )} - {shouldRenderSection('connectivity') && ( + {shouldRenderSection('local') && (
- {renderSectionHeader('connectivity')} - {isSectionVisible('connectivity') && appSettings && ( - )} @@ -241,7 +207,6 @@ export function SettingsModal(props: SettingsModalProps) { health={health} onSaveAppSettings={onSaveAppSettings} onHealthRefresh={onHealthRefresh} - onLocalLabelChange={onLocalLabelChange} blockedKeys={blockedKeys} blockedNames={blockedNames} onToggleBlockedKey={onToggleBlockedKey} diff --git a/frontend/src/components/settings/SettingsConnectivitySection.tsx b/frontend/src/components/settings/SettingsConnectivitySection.tsx deleted file mode 100644 index fedbb82..0000000 --- a/frontend/src/components/settings/SettingsConnectivitySection.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Input } from '../ui/input'; -import { Label } from '../ui/label'; -import { Button } from '../ui/button'; -import { Separator } from '../ui/separator'; -import { toast } from '../ui/sonner'; -import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types'; - -export function SettingsConnectivitySection({ - appSettings, - health, - pageMode, - onSaveAppSettings, - onReboot, - onClose, - className, -}: { - appSettings: AppSettings; - health: HealthStatus | null; - pageMode: boolean; - onSaveAppSettings: (update: AppSettingsUpdate) => Promise; - onReboot: () => Promise; - onClose: () => void; - className?: string; -}) { - const [maxRadioContacts, setMaxRadioContacts] = useState(''); - const [busy, setBusy] = useState(false); - const [rebooting, setRebooting] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - setMaxRadioContacts(String(appSettings.max_radio_contacts)); - }, [appSettings]); - - const handleSave = async () => { - setError(null); - setBusy(true); - - try { - const update: AppSettingsUpdate = {}; - const newMaxRadioContacts = parseInt(maxRadioContacts, 10); - if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings.max_radio_contacts) { - update.max_radio_contacts = newMaxRadioContacts; - } - if (Object.keys(update).length > 0) { - await onSaveAppSettings(update); - } - toast.success('Connectivity settings saved'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save'); - } finally { - setBusy(false); - } - }; - - const handleReboot = async () => { - if ( - !confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.') - ) { - return; - } - setError(null); - setBusy(true); - setRebooting(true); - - try { - await onReboot(); - if (!pageMode) { - onClose(); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to reboot radio'); - } finally { - setRebooting(false); - setBusy(false); - } - }; - - return ( -
-
- - {health?.connection_info ? ( -
-
- - {health.connection_info} - -
- ) : ( -
-
- Not connected -
- )} -
- - - -
- - setMaxRadioContacts(e.target.value)} - /> -

- Favorite contacts load first, then recent non-repeater contacts until this limit is - reached (1-1000) -

-
- - - - - - - - {error && ( -
- {error} -
- )} -
- ); -} diff --git a/frontend/src/components/settings/SettingsDatabaseSection.tsx b/frontend/src/components/settings/SettingsDatabaseSection.tsx index b7b6d6e..7226276 100644 --- a/frontend/src/components/settings/SettingsDatabaseSection.tsx +++ b/frontend/src/components/settings/SettingsDatabaseSection.tsx @@ -6,13 +6,6 @@ import { Separator } from '../ui/separator'; import { toast } from '../ui/sonner'; import { api } from '../../api'; import { formatTime } from '../../utils/messageParser'; -import { - captureLastViewedConversationFromHash, - getReopenLastConversationEnabled, - setReopenLastConversationEnabled, -} from '../../utils/lastViewedConversation'; -import { ThemeSelector } from './ThemeSelector'; -import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel'; import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types'; export function SettingsDatabaseSection({ @@ -20,7 +13,6 @@ export function SettingsDatabaseSection({ health, onSaveAppSettings, onHealthRefresh, - onLocalLabelChange, blockedKeys = [], blockedNames = [], onToggleBlockedKey, @@ -31,7 +23,6 @@ export function SettingsDatabaseSection({ health: HealthStatus | null; onSaveAppSettings: (update: AppSettingsUpdate) => Promise; onHealthRefresh: () => Promise; - onLocalLabelChange?: (label: LocalLabel) => void; blockedKeys?: string[]; blockedNames?: string[]; onToggleBlockedKey?: (key: string) => void; @@ -42,11 +33,6 @@ export function SettingsDatabaseSection({ const [cleaning, setCleaning] = useState(false); const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false); const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false); - const [reopenLastConversation, setReopenLastConversation] = useState( - getReopenLastConversationEnabled - ); - const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text); - const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -117,14 +103,6 @@ export function SettingsDatabaseSection({ } }; - const handleToggleReopenLastConversation = (enabled: boolean) => { - setReopenLastConversation(enabled); - setReopenLastConversationEnabled(enabled); - if (enabled) { - captureLastViewedConversationFromHash(); - } - }; - return (
@@ -230,66 +208,6 @@ export function SettingsDatabaseSection({ -
- - -
- Color Scheme - -
- - -

- These settings apply only to this device/browser. They do not sync to server settings. -

-
- - - -
- -
- { - const text = e.target.value; - setLocalLabelText(text); - setLocalLabel(text, localLabelColor); - onLocalLabelChange?.({ text, color: localLabelColor }); - }} - placeholder="e.g. Home Base, Field Radio 2" - aria-label="Local label text" - className="flex-1" - /> - { - const color = e.target.value; - setLocalLabelColor(color); - setLocalLabel(localLabelText, color); - onLocalLabelChange?.({ text: localLabelText, color }); - }} - aria-label="Local label color" - className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5" - /> -
-

- Display a colored banner at the top of the page to identify this instance. This applies - only to this device/browser. -

-
- - -

diff --git a/frontend/src/components/settings/SettingsIdentitySection.tsx b/frontend/src/components/settings/SettingsIdentitySection.tsx deleted file mode 100644 index 1ac2b8f..0000000 --- a/frontend/src/components/settings/SettingsIdentitySection.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Input } from '../ui/input'; -import { Label } from '../ui/label'; -import { Button } from '../ui/button'; -import { Separator } from '../ui/separator'; -import { toast } from '../ui/sonner'; -import type { - AppSettings, - AppSettingsUpdate, - HealthStatus, - RadioConfig, - RadioConfigUpdate, -} from '../../types'; - -export function SettingsIdentitySection({ - config, - health, - appSettings, - pageMode, - onSave, - onSaveAppSettings, - onSetPrivateKey, - onReboot, - onAdvertise, - onClose, - className, -}: { - config: RadioConfig; - health: HealthStatus | null; - appSettings: AppSettings; - pageMode: boolean; - onSave: (update: RadioConfigUpdate) => Promise; - onSaveAppSettings: (update: AppSettingsUpdate) => Promise; - onSetPrivateKey: (key: string) => Promise; - onReboot: () => Promise; - onAdvertise: () => Promise; - onClose: () => void; - className?: string; -}) { - const [name, setName] = useState(''); - const [privateKey, setPrivateKey] = useState(''); - const [advertIntervalHours, setAdvertIntervalHours] = useState('0'); - const [floodScope, setFloodScope] = useState(''); - const [busy, setBusy] = useState(false); - const [rebooting, setRebooting] = useState(false); - const [advertising, setAdvertising] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - setName(config.name); - }, [config]); - - useEffect(() => { - setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600))); - setFloodScope(appSettings.flood_scope); - }, [appSettings]); - - const handleSaveIdentity = async () => { - setError(null); - setBusy(true); - - try { - const update: RadioConfigUpdate = { name }; - await onSave(update); - - const appUpdate: AppSettingsUpdate = {}; - const hours = parseInt(advertIntervalHours, 10); - const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600; - if (newAdvertInterval !== appSettings.advert_interval) { - appUpdate.advert_interval = newAdvertInterval; - } - if (floodScope !== appSettings.flood_scope) { - appUpdate.flood_scope = floodScope; - } - if (Object.keys(appUpdate).length > 0) { - await onSaveAppSettings(appUpdate); - } - - toast.success('Identity settings saved'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save'); - } finally { - setBusy(false); - } - }; - - const handleSetPrivateKey = async () => { - if (!privateKey.trim()) { - setError('Private key is required'); - return; - } - setError(null); - setBusy(true); - - try { - await onSetPrivateKey(privateKey.trim()); - setPrivateKey(''); - toast.success('Private key set, rebooting...'); - setRebooting(true); - await onReboot(); - if (!pageMode) { - onClose(); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to set private key'); - } finally { - setRebooting(false); - setBusy(false); - } - }; - - const handleAdvertise = async () => { - setAdvertising(true); - try { - await onAdvertise(); - } finally { - setAdvertising(false); - } - }; - - return ( -

-
- - -
- -
- - setName(e.target.value)} /> -
- -
- -
- setAdvertIntervalHours(e.target.value)} - className="w-28" - /> - hours (0 = off) -
-

- How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour. - Recommended: 24 hours or higher. -

-
- -
- - setFloodScope(e.target.value)} - placeholder="#MyRegion" - /> -

- Tag outgoing flood messages with a region name (e.g. #MyRegion). Repeaters with this - region configured will prioritize your traffic. Leave empty to disable. -

-
- - - - - -
- - setPrivateKey(e.target.value)} - placeholder="64-character hex private key" - /> - -
- - - -
- -

- Send a flood advertisement to announce your presence on the mesh network. -

- - {!health?.radio_connected && ( -

Radio not connected

- )} -
- - {error && ( -
- {error} -
- )} -
- ); -} diff --git a/frontend/src/components/settings/SettingsLocalSection.tsx b/frontend/src/components/settings/SettingsLocalSection.tsx new file mode 100644 index 0000000..3adb34e --- /dev/null +++ b/frontend/src/components/settings/SettingsLocalSection.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; +import { Separator } from '../ui/separator'; +import { + captureLastViewedConversationFromHash, + getReopenLastConversationEnabled, + setReopenLastConversationEnabled, +} from '../../utils/lastViewedConversation'; +import { ThemeSelector } from './ThemeSelector'; +import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel'; + +export function SettingsLocalSection({ + onLocalLabelChange, + className, +}: { + onLocalLabelChange?: (label: LocalLabel) => void; + className?: string; +}) { + const [reopenLastConversation, setReopenLastConversation] = useState( + getReopenLastConversationEnabled + ); + const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text); + const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color); + + const handleToggleReopenLastConversation = (enabled: boolean) => { + setReopenLastConversation(enabled); + setReopenLastConversationEnabled(enabled); + if (enabled) { + captureLastViewedConversationFromHash(); + } + }; + + return ( +
+

+ These settings apply only to this device/browser. +

+ +
+ + +
+ + + +
+ +
+ { + const text = e.target.value; + setLocalLabelText(text); + setLocalLabel(text, localLabelColor); + onLocalLabelChange?.({ text, color: localLabelColor }); + }} + placeholder="e.g. Home Base, Field Radio 2" + aria-label="Local label text" + className="flex-1" + /> + { + const color = e.target.value; + setLocalLabelColor(color); + setLocalLabel(localLabelText, color); + onLocalLabelChange?.({ text: localLabelText, color }); + }} + aria-label="Local label color" + className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5" + /> +
+

+ Display a colored banner at the top of the page to identify this instance. +

+
+ + + + +
+ ); +} diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index 10ead8a..d7a6f57 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -5,23 +5,41 @@ import { Button } from '../ui/button'; import { Separator } from '../ui/separator'; import { toast } from '../ui/sonner'; import { RADIO_PRESETS } from '../../utils/radioPresets'; -import type { RadioConfig, RadioConfigUpdate } from '../../types'; +import type { + AppSettings, + AppSettingsUpdate, + HealthStatus, + RadioConfig, + RadioConfigUpdate, +} from '../../types'; export function SettingsRadioSection({ config, + health, + appSettings, pageMode, onSave, + onSaveAppSettings, + onSetPrivateKey, onReboot, + onAdvertise, onClose, className, }: { config: RadioConfig; + health: HealthStatus | null; + appSettings: AppSettings; pageMode: boolean; onSave: (update: RadioConfigUpdate) => Promise; + onSaveAppSettings: (update: AppSettingsUpdate) => Promise; + onSetPrivateKey: (key: string) => Promise; onReboot: () => Promise; + onAdvertise: () => Promise; onClose: () => void; className?: string; }) { + // Radio config state + const [name, setName] = useState(''); const [lat, setLat] = useState(''); const [lon, setLon] = useState(''); const [txPower, setTxPower] = useState(''); @@ -30,12 +48,28 @@ export function SettingsRadioSection({ const [sf, setSf] = useState(''); const [cr, setCr] = useState(''); const [gettingLocation, setGettingLocation] = useState(false); - const [busy, setBusy] = useState(false); const [rebooting, setRebooting] = useState(false); const [error, setError] = useState(null); + // Identity state + const [privateKey, setPrivateKey] = useState(''); + const [identityBusy, setIdentityBusy] = useState(false); + const [identityRebooting, setIdentityRebooting] = useState(false); + const [identityError, setIdentityError] = useState(null); + + // Flood & advert control state + const [advertIntervalHours, setAdvertIntervalHours] = useState('0'); + const [floodScope, setFloodScope] = useState(''); + const [maxRadioContacts, setMaxRadioContacts] = useState(''); + const [floodBusy, setFloodBusy] = useState(false); + const [floodError, setFloodError] = useState(null); + + // Advertise state + const [advertising, setAdvertising] = useState(false); + useEffect(() => { + setName(config.name); setLat(String(config.lat)); setLon(String(config.lon)); setTxPower(String(config.tx_power)); @@ -45,6 +79,12 @@ export function SettingsRadioSection({ setCr(String(config.radio.cr)); }, [config]); + useEffect(() => { + setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600))); + setFloodScope(appSettings.flood_scope); + setMaxRadioContacts(String(appSettings.max_radio_contacts)); + }, [appSettings]); + const currentPreset = useMemo(() => { const freqNum = parseFloat(freq); const bwNum = parseFloat(bw); @@ -125,6 +165,7 @@ export function SettingsRadioSection({ try { const update: RadioConfigUpdate = { + name, lat: parsedLat, lon: parsedLon, tx_power: parsedTxPower, @@ -150,8 +191,98 @@ export function SettingsRadioSection({ } }; + const handleSetPrivateKey = async () => { + if (!privateKey.trim()) { + setIdentityError('Private key is required'); + return; + } + setIdentityError(null); + setIdentityBusy(true); + + try { + await onSetPrivateKey(privateKey.trim()); + setPrivateKey(''); + toast.success('Private key set, rebooting...'); + setIdentityRebooting(true); + await onReboot(); + if (!pageMode) { + onClose(); + } + } catch (err) { + setIdentityError(err instanceof Error ? err.message : 'Failed to set private key'); + } finally { + setIdentityRebooting(false); + setIdentityBusy(false); + } + }; + + const handleSaveFloodSettings = async () => { + setFloodError(null); + setFloodBusy(true); + + try { + const update: AppSettingsUpdate = {}; + const hours = parseInt(advertIntervalHours, 10); + const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600; + if (newAdvertInterval !== appSettings.advert_interval) { + update.advert_interval = newAdvertInterval; + } + if (floodScope !== appSettings.flood_scope) { + update.flood_scope = floodScope; + } + const newMaxRadioContacts = parseInt(maxRadioContacts, 10); + if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings.max_radio_contacts) { + update.max_radio_contacts = newMaxRadioContacts; + } + if (Object.keys(update).length > 0) { + await onSaveAppSettings(update); + } + toast.success('Settings saved'); + } catch (err) { + setFloodError(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setFloodBusy(false); + } + }; + + const handleAdvertise = async () => { + setAdvertising(true); + try { + await onAdvertise(); + } finally { + setAdvertising(false); + } + }; + return (
+ {/* Connection display */} +
+ + {health?.connection_info ? ( +
+
+ + {health.connection_info} + +
+ ) : ( +
+
+ Not connected +
+ )} +
+ + {/* Radio Name */} +
+ + setName(e.target.value)} /> +
+ + + + {/* Radio Config */}
+
+ +
+ + setPrivateKey(e.target.value)} + placeholder="64-character hex private key" + /> + +
+ + {identityError && ( +
+ {identityError} +
+ )} + + + + {/* Flood & Advert Control */} +
+ +
+ +
+ +
+ setAdvertIntervalHours(e.target.value)} + className="w-28" + /> + hours (0 = off) +
+

+ How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour. + Recommended: 24 hours or higher. +

+
+ +
+ + setFloodScope(e.target.value)} + placeholder="#MyRegion" + /> +

+ Tag outgoing flood messages with a region name (e.g. #MyRegion). Repeaters with this + region configured will prioritize your traffic. Leave empty to disable. +

+
+ +
+ + setMaxRadioContacts(e.target.value)} + /> +

+ Favorite contacts load first, then recent non-repeater contacts until this limit is + reached (1-1000) +

+
+ + {floodError && ( +
+ {floodError} +
+ )} + + + + + + {/* Send Advertisement */} +
+ +

+ Send a flood advertisement to announce your presence on the mesh network. +

+ + {!health?.radio_connected && ( +

Radio not connected

+ )} +
); } diff --git a/frontend/src/components/settings/settingsConstants.ts b/frontend/src/components/settings/settingsConstants.ts index 6cbcaef..136fbd0 100644 --- a/frontend/src/components/settings/settingsConstants.ts +++ b/frontend/src/components/settings/settingsConstants.ts @@ -1,17 +1,15 @@ export type SettingsSection = | 'radio' - | 'identity' - | 'connectivity' - | 'mqtt' + | 'local' | 'database' | 'bot' + | 'mqtt' | 'statistics' | 'about'; export const SETTINGS_SECTION_ORDER: SettingsSection[] = [ 'radio', - 'identity', - 'connectivity', + 'local', 'database', 'bot', 'mqtt', @@ -21,9 +19,8 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [ export const SETTINGS_SECTION_LABELS: Record = { radio: '📻 Radio', - identity: '🪪 Identity', - connectivity: '📡 Connectivity', - database: '🗄️ Database & Interface', + local: '🖥️ Local Configuration', + database: '🗄️ Database & Messaging', bot: '🤖 Bots', mqtt: '📤 MQTT', statistics: '📊 Statistics', diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index bcf9767..5fde04c 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -120,12 +120,11 @@ vi.mock('../components/SettingsModal', () => ({ SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
{desktopSection ?? 'none'}
), - SETTINGS_SECTION_ORDER: ['radio', 'identity', 'connectivity', 'database', 'bot'], + SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'], SETTINGS_SECTION_LABELS: { radio: '📻 Radio', - identity: '🪪 Identity', - connectivity: '📡 Connectivity', - database: '🗄️ Database', + local: '🖥️ Local Configuration', + database: '🗄️ Database & Messaging', bot: '🤖 Bot', }, })); @@ -274,10 +273,10 @@ describe('App favorite toggle flow', () => { expect(screen.getByTestId('settings-modal-section')).toHaveTextContent('radio'); }); - fireEvent.click(screen.getAllByRole('button', { name: /Identity/i })[0]); + fireEvent.click(screen.getAllByRole('button', { name: /Local Configuration/i })[0]); await waitFor(() => { - expect(screen.getByTestId('settings-modal-section')).toHaveTextContent('identity'); + expect(screen.getByTestId('settings-modal-section')).toHaveTextContent('local'); }); fireEvent.click(screen.getByRole('button', { name: 'Back to Chat' })); diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index 857062d..d00fc44 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -89,12 +89,11 @@ vi.mock('../components/NewMessageModal', () => ({ vi.mock('../components/SettingsModal', () => ({ SettingsModal: () => null, - SETTINGS_SECTION_ORDER: ['radio', 'identity', 'connectivity', 'database', 'bot'], + SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'], SETTINGS_SECTION_LABELS: { radio: 'Radio', - identity: 'Identity', - connectivity: 'Connectivity', - database: 'Database', + local: 'Local Configuration', + database: 'Database & Messaging', bot: 'Bot', }, })); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 4efac0d..03eb854 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -149,9 +149,14 @@ function setMatchMedia(matches: boolean) { }); } -function openConnectivitySection() { - const connectivityToggle = screen.getByRole('button', { name: /Connectivity/i }); - fireEvent.click(connectivityToggle); +function openRadioSection() { + const radioToggle = screen.getByRole('button', { name: /Radio/i }); + fireEvent.click(radioToggle); +} + +function openLocalSection() { + const localToggle = screen.getByRole('button', { name: /Local Configuration/i }); + fireEvent.click(localToggle); } function openMqttSection() { @@ -200,10 +205,9 @@ describe('SettingsModal', () => { expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument(); }); - it('shows favorite-first contact sync helper text in connectivity tab', async () => { + it('shows favorite-first contact sync helper text in radio tab', async () => { renderModal(); - - openConnectivitySection(); + openRadioSection(); expect( screen.getByText( @@ -214,13 +218,14 @@ describe('SettingsModal', () => { it('saves changed max contacts value through onSaveAppSettings', async () => { const { onSaveAppSettings } = renderModal(); - - openConnectivitySection(); + openRadioSection(); const maxContactsInput = screen.getByLabelText('Max Contacts on Radio'); fireEvent.change(maxContactsInput, { target: { value: '250' } }); - fireEvent.click(screen.getByRole('button', { name: 'Save Settings' })); + // Click the "Save Settings" button in the Flood & Advert Control section + const saveButtons = screen.getAllByRole('button', { name: 'Save Settings' }); + fireEvent.click(saveButtons[0]); await waitFor(() => { expect(onSaveAppSettings).toHaveBeenCalledWith({ max_radio_contacts: 250 }); @@ -231,9 +236,11 @@ describe('SettingsModal', () => { const { onSaveAppSettings } = renderModal({ appSettings: { ...baseSettings, max_radio_contacts: 200 }, }); + openRadioSection(); - openConnectivitySection(); - fireEvent.click(screen.getByRole('button', { name: 'Save Settings' })); + // Click the "Save Settings" button in the Flood & Advert Control section + const saveButtons = screen.getAllByRole('button', { name: 'Save Settings' }); + fireEvent.click(saveButtons[0]); await waitFor(() => { expect(onSaveAppSettings).not.toHaveBeenCalled(); @@ -247,22 +254,22 @@ describe('SettingsModal', () => { }); expect(screen.getByText('No bots configured')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /Connectivity/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Local Configuration/i })).not.toBeInTheDocument(); expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument(); }); it('toggles sections in mobile accordion mode', () => { renderModal({ mobile: true }); - const identityToggle = screen.getAllByRole('button', { name: /Identity/i })[0]; + const localToggle = screen.getAllByRole('button', { name: /Local Configuration/i })[0]; expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('Public Key')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Local label text')).not.toBeInTheDocument(); - fireEvent.click(identityToggle); - expect(screen.getByLabelText('Public Key')).toBeInTheDocument(); + fireEvent.click(localToggle); + expect(screen.getByLabelText('Local label text')).toBeInTheDocument(); - fireEvent.click(identityToggle); - expect(screen.queryByLabelText('Public Key')).not.toBeInTheDocument(); + fireEvent.click(localToggle); + expect(screen.queryByLabelText('Local label text')).not.toBeInTheDocument(); }); it('clears stale errors when switching external desktop sections', async () => { @@ -316,6 +323,7 @@ describe('SettingsModal', () => { onSetPrivateKey, onReboot, }); + openRadioSection(); fireEvent.click(screen.getByRole('button', { name: 'Save Radio Config & Reboot' })); await waitFor(() => { @@ -324,7 +332,6 @@ describe('SettingsModal', () => { }); expect(onClose).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole('button', { name: /Identity/i })); fireEvent.change(screen.getByLabelText('Set Private Key (write-only)'), { target: { value: 'a'.repeat(64) }, }); @@ -340,7 +347,7 @@ describe('SettingsModal', () => { it('stores and clears reopen-last-conversation preference locally', () => { window.location.hash = '#raw'; renderModal(); - openDatabaseSection(); + openLocalSection(); const checkbox = screen.getByLabelText('Reopen to last viewed channel/conversation'); expect(checkbox).not.toBeChecked(); diff --git a/tests/e2e/specs/radio-settings.spec.ts b/tests/e2e/specs/radio-settings.spec.ts index 7362bc6..08b2e18 100644 --- a/tests/e2e/specs/radio-settings.spec.ts +++ b/tests/e2e/specs/radio-settings.spec.ts @@ -19,14 +19,12 @@ test.describe('Radio settings', () => { // --- Step 1: Change the name via settings UI --- await page.getByText('Settings').click(); - await page.getByRole('button', { name: /Identity/i }).click(); - const nameInput = page.locator('#name'); await nameInput.clear(); await nameInput.fill(testName); - await page.getByRole('button', { name: 'Save Identity Settings' }).click(); - await expect(page.getByText('Identity settings saved')).toBeVisible({ timeout: 10_000 }); + await page.getByRole('button', { name: 'Save Radio Config & Reboot' }).click(); + await expect(page.getByText('Radio config saved, rebooting...')).toBeVisible({ timeout: 10_000 }); // Exit settings page mode await page.getByRole('button', { name: /Back to Chat/i }).click(); @@ -40,7 +38,6 @@ test.describe('Radio settings', () => { await expect(page.getByText('Connected')).toBeVisible({ timeout: 15_000 }); await page.getByText('Settings').click(); - await page.getByRole('button', { name: /Identity/i }).click(); await expect(page.locator('#name')).toHaveValue(testName, { timeout: 10_000 }); } finally { // Always restore original name, even when assertions fail. diff --git a/tests/e2e/specs/reopen-last-conversation.spec.ts b/tests/e2e/specs/reopen-last-conversation.spec.ts index 7ca9f27..64b2a12 100644 --- a/tests/e2e/specs/reopen-last-conversation.spec.ts +++ b/tests/e2e/specs/reopen-last-conversation.spec.ts @@ -37,7 +37,7 @@ test.describe('Reopen last conversation (device-local)', () => { ).toBeVisible(); await page.getByRole('button', { name: 'Settings' }).click(); - await page.getByRole('button', { name: /Database & Interface/i }).click(); + await page.getByRole('button', { name: /Local Configuration/i }).click(); await page.getByLabel('Reopen to last viewed channel/conversation').check(); await page.getByRole('button', { name: 'Back to Chat' }).click(); @@ -59,7 +59,7 @@ test.describe('Reopen last conversation (device-local)', () => { ).toBeVisible(); await page.getByRole('button', { name: 'Settings' }).click(); - await page.getByRole('button', { name: /Database & Interface/i }).click(); + await page.getByRole('button', { name: /Local Configuration/i }).click(); const reopenToggle = page.getByLabel('Reopen to last viewed channel/conversation'); await reopenToggle.check();