mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
feat: add an advanced switch that hides power-user settings until turned on
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user