diff --git a/components/settings/__tests__/advanced-settings.test.tsx b/components/settings/__tests__/advanced-settings.test.tsx new file mode 100644 index 000000000..c25c9cf5c --- /dev/null +++ b/components/settings/__tests__/advanced-settings.test.tsx @@ -0,0 +1,61 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { AdvancedSettingsProvider, AdvancedSettingsSwitch, SettingItem, SettingsSection } from '../settings-section'; +import { useSettingsStore } from '@/stores/settings-store'; + +function Page() { + return ( + + + basic + adv + + + adv + + + ); +} + +describe('Advanced settings switch', () => { + beforeEach(() => { + act(() => { useSettingsStore.setState({ showAdvancedSettings: false }); }); + }); + + it('hides advanced rows and sections and counts them', () => { + render(); + expect(screen.getByText('App passwords')).toBeInTheDocument(); + expect(screen.queryByText('API keys')).toBeNull(); + expect(screen.queryByText('Encryption')).toBeNull(); + expect(screen.queryByText('Public keys')).toBeNull(); + // The hidden section counts once; its rows never mount, so they do not count twice. + expect(screen.getByTestId('settings-hidden-advanced')).toHaveTextContent('hidden_count'); + expect(useSettingsStore.getState().showAdvancedSettings).toBe(false); + }); + + it('reveals them from the page footer link', () => { + render(); + fireEvent.click(screen.getByText('show')); + expect(useSettingsStore.getState().showAdvancedSettings).toBe(true); + expect(screen.getByText('API keys')).toBeInTheDocument(); + expect(screen.getByText('Encryption')).toBeInTheDocument(); + expect(screen.getByText('Public keys')).toBeInTheDocument(); + expect(screen.queryByTestId('settings-hidden-advanced')).toBeNull(); + }); + + it('toggles from the sidebar switch', () => { + render(<>); + const sw = screen.getByTestId('settings-advanced-switch'); + expect(sw).toHaveAttribute('aria-checked', 'false'); + fireEvent.click(sw); + expect(sw).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByText('API keys')).toBeInTheDocument(); + fireEvent.click(sw); + expect(screen.queryByText('API keys')).toBeNull(); + }); + + it('shows advanced rows when no provider scopes a page', () => { + render(adv); + expect(screen.getByText('API keys')).toBeInTheDocument(); + }); +}); diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index a9e162df2..b3ebcda2b 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -69,6 +69,7 @@ import { PluginsSettings } from '@/components/settings/plugins-settings'; import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings'; +import { AdvancedSettingsProvider, AdvancedSettingsSwitch } from '@/components/settings/settings-section'; import { appPath, buildSettingsPath, parseSettingsPath } from '@/lib/deep-links'; import { consumePendingDeepLink, subscribePendingDeepLink } from '@/lib/deep-link-handoff'; import { useDeepLinkUrl } from '@/hooks/use-deep-link-url'; @@ -691,7 +692,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? ''; const renderTabContent = () => ( - <> + {managedAccountId && managedAccount && ( + + ); +} + +/** The sidebar switch that reveals advanced rows and sections everywhere. */ +export function AdvancedSettingsSwitch() { + const t = useTranslations('settings.advanced_switch'); + const show = useSettingsStore((s) => s.showAdvancedSettings); + const updateSetting = useSettingsStore((s) => s.updateSetting); + const labelId = useId(); + return ( +
+ {t('label')} + + updateSetting('showAdvancedSettings', checked)} testId="settings-advanced-switch" /> + +
+ ); +} + +/** Small marker next to a label of a row that only shows with Advanced on. */ +function AdvancedBadge() { + const t = useTranslations('settings.advanced_switch'); + return ( + + {t('badge')} + + ); +} /** * Lets the controls inside a SettingItem borrow the row's visible label as @@ -15,13 +110,22 @@ interface SettingsSectionProps { title: string; description?: string; children: ReactNode; + /** Hidden until the Advanced switch is on. */ + advanced?: boolean; + /** Anchor for section deep links and the in-page jump row. */ + id?: string; } -export function SettingsSection({ title, description, children }: SettingsSectionProps) { +export function SettingsSection({ title, description, children, advanced, id }: SettingsSectionProps) { + const visible = useAdvancedVisible(advanced); + if (!visible) return null; return ( -
+
-

{title}

+

+ {title} + {advanced && } +

{description && (

{description}

)} @@ -36,19 +140,30 @@ interface SettingItemProps { description?: string; children: ReactNode; locked?: boolean; + /** Hidden until the Advanced switch is on. */ + advanced?: boolean; + /** Indented under the row above it, for a sub-option of a toggle. */ + nested?: boolean; } -export function SettingItem({ label, description, children, locked }: SettingItemProps) { +export function SettingItem({ label, description, children, locked, advanced, nested }: SettingItemProps) { const labelId = useId(); + const visible = useAdvancedVisible(advanced); + if (!visible) return null; return (
{locked && } + {advanced && }
{description && (

{description}

diff --git a/locales/en/common.json b/locales/en/common.json index 1516905e3..2cddb793b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1339,17 +1339,17 @@ "confirm_revoke_message_this": "This device will stop receiving background notifications immediately." }, "push_all": { - "title": "All accounts", - "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", - "select_all": "Select every connected account", - "state_on": "notifications on", - "state_off": "notifications off", - "enable_selected": "Enable for selected", - "disable_selected": "Disable for selected", - "result_enabled": "Notifications enabled for {count} accounts.", - "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", - "result_disabled": "Notifications disabled for {count} accounts." - }, + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Notification Sound", "description": "Choose which sound to play for notifications", @@ -2532,6 +2532,12 @@ "preview": { "label": "Preview" } + }, + "advanced_switch": { + "label": "Show advanced settings", + "badge": "Advanced", + "hidden_count": "{count, plural, one {1 advanced setting is hidden on this page.} other {# advanced settings are hidden on this page.}}", + "show": "Show advanced settings" } }, "errors": { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index a44bc6006..9f77a1277 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -603,6 +603,9 @@ interface SettingsState { hideAccountSwitcher: boolean; showRailAccountList: boolean; proInterface: boolean; + // Settings marked `advanced` (power-user and integrator rows) stay hidden + // until this is on. Synced like the rest: it is a preference, not a device. + showAdvancedSettings: boolean; // Unified Mailbox enableUnifiedMailbox: boolean; @@ -889,6 +892,7 @@ const DEFAULT_SETTINGS = { hideAccountSwitcher: false, showRailAccountList: false, proInterface: false, + showAdvancedSettings: false, // Unified Mailbox enableUnifiedMailbox: false, @@ -1228,6 +1232,7 @@ export const useSettingsStore = create()( toolbarPosition: state.toolbarPosition, hideAccountSwitcher: state.hideAccountSwitcher, showRailAccountList: state.showRailAccountList, + showAdvancedSettings: state.showAdvancedSettings, // proInterface is intentionally omitted - it's a per-device choice // (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced. enableUnifiedMailbox: state.enableUnifiedMailbox,