feat: add an advanced switch that hides power-user settings until turned on

This commit is contained in:
Linus Rath
2026-10-19 17:20:38 +02:00
parent 391ed6c018
commit 988416aa3d
5 changed files with 213 additions and 19 deletions
@@ -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 (
<AdvancedSettingsProvider>
<SettingsSection title="Other apps">
<SettingItem label="App passwords"><span>basic</span></SettingItem>
<SettingItem label="API keys" advanced><span>adv</span></SettingItem>
</SettingsSection>
<SettingsSection title="Encryption" advanced>
<SettingItem label="Public keys" advanced><span>adv</span></SettingItem>
</SettingsSection>
</AdvancedSettingsProvider>
);
}
describe('Advanced settings switch', () => {
beforeEach(() => {
act(() => { useSettingsStore.setState({ showAdvancedSettings: false }); });
});
it('hides advanced rows and sections and counts them', () => {
render(<Page />);
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(<Page />);
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(<><AdvancedSettingsSwitch /><Page /></>);
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(<SettingItem label="API keys" advanced><span>adv</span></SettingItem>);
expect(screen.getByText('API keys')).toBeInTheDocument();
});
});
+9 -2
View File
@@ -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 = () => (
<>
<AdvancedSettingsProvider key={effectiveActiveTab}>
{managedAccountId && managedAccount && (
<button
type="button"
@@ -770,7 +771,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
</div>
</>
)}
</>
</AdvancedSettingsProvider>
);
// Mobile layout
@@ -897,6 +898,9 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
))}
</div>
<div className="border-t border-border px-2 py-2">
<AdvancedSettingsSwitch />
</div>
<div className="border-t border-border px-5 py-3">
<button
onClick={() => logout()}
@@ -1052,6 +1056,9 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
))}
</div>
</div>
<div className="border-t border-border px-1 py-1">
<AdvancedSettingsSwitch />
</div>
</div>
<ResizeHandle
+121 -6
View File
@@ -1,8 +1,103 @@
'use client';
import { ReactNode, createContext, useContext, useId } from 'react';
import { ReactNode, createContext, useContext, useEffect, useId, useState, useCallback, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Lock } from '@/components/icons';
import { cn } from '@/lib/utils';
import { useSettingsStore } from '@/stores/settings-store';
/**
* The Advanced switch. Rows and sections marked `advanced` render nothing
* while it is off, but report themselves so the page can say how many it
* hid. The provider wraps one settings page; without one (tests, plugin
* previews) advanced rows simply show.
*/
interface AdvancedSettingsContextValue {
show: boolean;
register: (id: string, hidden: boolean) => void;
}
const AdvancedSettingsContext = createContext<AdvancedSettingsContextValue | null>(null);
export function AdvancedSettingsProvider({ children }: { children: ReactNode }) {
const show = useSettingsStore((s) => s.showAdvancedSettings);
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
const register = useCallback((id: string, isHidden: boolean) => {
setHidden((prev) => {
if (prev.has(id) === isHidden) return prev;
const next = new Set(prev);
if (isHidden) next.add(id); else next.delete(id);
return next;
});
}, []);
const value = useMemo(() => ({ show, register }), [show, register]);
return (
<AdvancedSettingsContext.Provider value={value}>
{children}
<HiddenAdvancedNotice count={hidden.size} />
</AdvancedSettingsContext.Provider>
);
}
/** True when this row or section should render; registers it as hidden otherwise. */
function useAdvancedVisible(advanced: boolean | undefined): boolean {
const ctx = useContext(AdvancedSettingsContext);
const id = useId();
const hidden = Boolean(advanced) && ctx !== null && !ctx.show;
const register = ctx?.register;
useEffect(() => {
if (!register) return;
register(id, hidden);
return () => register(id, false);
}, [register, id, hidden]);
return !hidden;
}
function HiddenAdvancedNotice({ count }: { count: number }) {
const t = useTranslations('settings.advanced_switch');
const updateSetting = useSettingsStore((s) => s.updateSetting);
if (count === 0) return null;
return (
<div
data-testid="settings-hidden-advanced"
className="mt-6 flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
>
<span>{t('hidden_count', { count })}</span>
<button
type="button"
onClick={() => updateSetting('showAdvancedSettings', true)}
className="font-medium text-primary hover:underline"
>
{t('show')}
</button>
</div>
);
}
/** 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 (
<div className="flex items-center justify-between gap-2 px-3 py-2 text-sm">
<span id={labelId}>{t('label')}</span>
<SettingLabelContext.Provider value={labelId}>
<ToggleSwitch checked={show} onChange={(checked) => updateSetting('showAdvancedSettings', checked)} testId="settings-advanced-switch" />
</SettingLabelContext.Provider>
</div>
);
}
/** Small marker next to a label of a row that only shows with Advanced on. */
function AdvancedBadge() {
const t = useTranslations('settings.advanced_switch');
return (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent text-accent-foreground font-medium">
{t('badge')}
</span>
);
}
/**
* 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 (
<div data-search-label={title} className="space-y-4">
<div data-search-label={title} data-settings-section={id} className="space-y-4 scroll-mt-4">
<div>
<h3 className="text-lg font-medium text-foreground">{title}</h3>
<h3 className="text-lg font-medium text-foreground flex items-center gap-2">
{title}
{advanced && <AdvancedBadge />}
</h3>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
@@ -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 (
<div
data-search-label={label}
className={cn("flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0", locked && "opacity-60")}
className={cn(
"flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0",
locked && "opacity-60",
nested && "ms-4 ps-3 border-s-2 border-s-border border-b-transparent py-2",
)}
>
<div className="flex-1 min-w-0 sm:pe-4">
<div className="flex items-center gap-1.5">
<label id={labelId} className="text-sm font-medium text-foreground">{label}</label>
{locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />}
{advanced && <AdvancedBadge />}
</div>
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
+17 -11
View File
@@ -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": {
+5
View File
@@ -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<SettingsState>()(
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,