diff --git a/components/settings/__tests__/settings-palette-commands.test.tsx b/components/settings/__tests__/settings-palette-commands.test.tsx new file mode 100644 index 000000000..4d983ede1 --- /dev/null +++ b/components/settings/__tests__/settings-palette-commands.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import en from '@/locales/en/common.json'; + +// #40 / SET-27: in Settings the palette offered only "Go to Settings", and +// "out of office" found nothing. Each page the list shows is a command now, +// found by the words the settings search knows. + +vi.mock('next-intl', () => ({ + useTranslations: (ns?: string) => (key: string, values?: Record) => + `${ns ? `${ns}.` : ''}${key}${values?.name ? `(${values.name})` : ''}`, + useMessages: () => en, + useLocale: () => 'en', + useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }), +})); +vi.mock('@/hooks/use-media-query', () => ({ + useIsDesktop: () => true, + useIsMobile: () => false, + useMediaQuery: () => false, + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), +})); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: true }) })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); +vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); +vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => null })); + +const { SettingsApp } = await import('../settings-app'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { usePluginStore } = await import('@/stores/plugin-store'); +const { useThemeStore } = await import('@/stores/theme-store'); +const { useRegisteredCommandGroups, commandMatches } = await import('@/lib/command-registry'); + +type Groups = ReturnType; +let groups: Groups = []; +function Probe() { + groups = useRegisteredCommandGroups(); + return null; +} + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + // A server with Sieve and vacation, so Filters and Out of office are pages. + const client = new Proxy({}, { + get: (_t, prop) => { + if (prop === 'then') return undefined; + if (prop === 'getAccountId') return () => 'acc'; + if (typeof prop === 'string' && prop.startsWith('supports')) return () => true; + return () => null; + }, + }); + useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: client as never }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never); + try { localStorage.setItem('settings-active-tab', 'appearance'); } catch { /* ignore */ } +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function settingsCommands() { + const group = groups.find((g) => g.label === 'sidebar.settings'); + return group?.commands ?? []; +} + +function find(query: string) { + return settingsCommands().filter((c) => commandMatches(query, c.label, c.keywords)); +} + +describe('Settings commands in the palette', () => { + it('offers "Go to " for the settings pages', async () => { + render(<>); + await act(async () => { await vi.advanceTimersByTimeAsync(100); }); + + const ids = settingsCommands().map((c) => c.id); + expect(ids).toEqual(expect.arrayContaining(['go-vacation', 'go-filters', 'go-security', 'go-account'])); + expect(settingsCommands().find((c) => c.id === 'go-vacation')?.label).toBe('command_palette.go_to(settings.tabs.vacation)'); + }); + + it('finds pages by the words the settings search knows', async () => { + render(<>); + await act(async () => { await vi.advanceTimersByTimeAsync(100); }); + + expect(find('out of office').map((c) => c.id)).toContain('go-vacation'); + expect(find('app password').map((c) => c.id)).toContain('go-security'); + expect(find('add rule').map((c) => c.id)).toContain('go-filters'); + }); + + it('opens the page it names', async () => { + const tree = () => <>; + const { rerender } = render(tree()); + await act(async () => { await vi.advanceTimersByTimeAsync(100); }); + + act(() => settingsCommands().find((c) => c.id === 'go-filters')!.run()); + expect(localStorage.getItem('settings-active-tab')).toBe('filters'); + // The palette reads the commands when it opens, after Settings drew + // the new page; a second render stands in for that here. + rerender(tree()); + rerender(tree()); + // The page that is open is not offered. + expect(settingsCommands().map((c) => c.id)).not.toContain('go-filters'); + }); +}); diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index e5e24d08b..3b74d30be 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -27,6 +27,7 @@ import { X, type AppIcon, } from '@/components/icons'; +import { useRegisterCommands } from '@/lib/command-registry'; import { Button } from '@/components/ui/button'; import { IconButton } from '@/components/ui/icon-button'; import { Input } from '@/components/ui/input'; @@ -262,6 +263,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = const router = useRouter(); const t = useTranslations('settings'); const tSidebar = useTranslations('sidebar'); + const tPalette = useTranslations('command_palette'); useDocumentTitle(tSidebar('settings')); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); @@ -656,10 +658,6 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = } }, [activeTab]); - if (!isAuthenticated) { - return null; - } - const supportsVacation = client?.supportsVacationResponse() ?? false; const supportsCalendar = client?.supportsCalendars() ?? false; const supportsSieve = client?.supportsSieve() ?? false; @@ -786,6 +784,26 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId, advanced: sub.advanced }); }; + // Ctrl/⌘+K in Settings: one "Go to " per page shown in the list, + // found by the same words as the settings search ("out of office", "app + // password", "add rule"). + useRegisterCommands( + tSidebar('settings'), + visibleTabs.map((tab) => ({ + id: `go-${tab.id}`, + label: tPalette('go_to', { name: tab.label }), + icon: tab.icon, + keywords: [tabSearchHaystacks[tab.id as Tab] ?? ''], + run: () => handleTabSelect(tab.id), + when: () => tab.id !== effectiveActiveTab, + })), + isAuthenticated, + ); + + if (!isAuthenticated) { + return null; + } + const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? ''; const renderTabContent = () => (