diff --git a/client/src/components/Settings/IntegrationsTab.tsx b/client/src/components/Settings/IntegrationsTab.tsx index 0a97916ce..3cb647e28 100644 --- a/client/src/components/Settings/IntegrationsTab.tsx +++ b/client/src/components/Settings/IntegrationsTab.tsx @@ -1,26 +1,25 @@ import Section from './Section' -import React, { useEffect, useId } from 'react' +import React, { useId } from 'react' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { Trash2, Copy, Terminal, Plus, Check, Key, KeyRound, ChevronRight, RefreshCw, AlertTriangle, Link2 } from 'lucide-react' -import { useAddonStore } from '../../store/addonStore' import PhotoProvidersSection from './PhotoProvidersSection' import AirTrailConnectionSection from './AirTrailConnectionSection' import DawarichConnectionSection from './DawarichConnectionSection' import LlmConnectionSection from './LlmConnectionSection' import ApiKeysSection from './ApiKeysSection' import ScopeGroupPicker from '../OAuth/ScopeGroupPicker' -import { useAuthStore } from '../../store/authStore' import ConfirmDialog from '../shared/ConfirmDialog' import { Tooltip } from '../shared/Tooltip' import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import { EditorField, INPUT, Segmented, TEXTAREA } from '../shared/dialogParts' import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingRows, SettingsHint, StatusPill } from './settingsKit' import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration' +import { useIntegrationGates } from './useIntegrationGates' export default function IntegrationsTab(): React.ReactElement { const S = useIntegrations() - const managed = useAuthStore((s) => s.managed) + const managed = S.managed return ( <> {/* Immich, Synology Photos and AirTrail all connect to a server the reader @@ -46,19 +45,11 @@ export default function IntegrationsTab(): React.ReactElement { function useIntegrations() { const { t, locale } = useTranslation() const toast = useToast() - const { isEnabled: addonEnabled, loadAddons } = useAddonStore() - const mcpEnabled = addonEnabled('mcp') - const airtrailEnabled = addonEnabled('airtrail') - const llmEnabled = addonEnabled('llm_parsing') - const dawarichEnabled = addonEnabled('dawarich') - - useEffect(() => { - loadAddons() - }, [loadAddons]) + const { mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, managed } = useIntegrationGates({ reloadAddons: true }) const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false }) - return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, ...mcp } + return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, managed, ...mcp } } type IntegrationsState = ReturnType diff --git a/client/src/components/Settings/useIntegrationGates.test.ts b/client/src/components/Settings/useIntegrationGates.test.ts new file mode 100644 index 000000000..774bed99b --- /dev/null +++ b/client/src/components/Settings/useIntegrationGates.test.ts @@ -0,0 +1,55 @@ +// FE-COMP-INTEGRATION-GATES-001 to -004: which integrations the settings shells offer. +import { renderHook } from '@testing-library/react'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useAddonStore } from '../../store/addonStore'; +import { useAuthStore } from '../../store/authStore'; +import { useIntegrationGates } from './useIntegrationGates'; + +function seedAddons(enabled: string[]) { + const loadAddons = vi.fn().mockResolvedValue(undefined); + seedStore(useAddonStore, { isEnabled: (id: string) => enabled.includes(id), loadAddons }); + return loadAddons; +} + +beforeEach(() => { + resetAllStores(); +}); + +describe('useIntegrationGates', () => { + it('FE-COMP-INTEGRATION-GATES-001: maps each addon to its flag', () => { + seedAddons(['mcp', 'llm_parsing']); + const { result } = renderHook(() => useIntegrationGates({ reloadAddons: false })); + expect(result.current).toMatchObject({ + memoriesEnabled: false, + mcpEnabled: true, + airtrailEnabled: false, + llmEnabled: true, + dawarichEnabled: false, + hasIntegrations: true, + }); + }); + + it('FE-COMP-INTEGRATION-GATES-002: any one integration addon opens the tab, none keeps it closed', () => { + for (const id of ['memories', 'mcp', 'airtrail', 'llm_parsing', 'dawarich']) { + seedAddons([id]); + expect(renderHook(() => useIntegrationGates({ reloadAddons: false })).result.current.hasIntegrations).toBe(true); + } + seedAddons(['vacay', 'atlas']); + expect(renderHook(() => useIntegrationGates({ reloadAddons: false })).result.current.hasIntegrations).toBe(false); + }); + + it('FE-COMP-INTEGRATION-GATES-003: reports a managed install', () => { + seedAddons([]); + seedStore(useAuthStore, { managed: true }); + expect(renderHook(() => useIntegrationGates({ reloadAddons: false })).result.current.managed).toBe(true); + }); + + it('FE-COMP-INTEGRATION-GATES-004: reloads the addon list on mount only when asked to', () => { + const quiet = seedAddons([]); + renderHook(() => useIntegrationGates({ reloadAddons: false })); + expect(quiet).not.toHaveBeenCalled(); + const loud = seedAddons([]); + renderHook(() => useIntegrationGates({ reloadAddons: true })); + expect(loud).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/Settings/useIntegrationGates.ts b/client/src/components/Settings/useIntegrationGates.ts new file mode 100644 index 000000000..ac6a67226 --- /dev/null +++ b/client/src/components/Settings/useIntegrationGates.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; +import { useAddonStore } from '../../store/addonStore'; +import { useAuthStore } from '../../store/authStore'; + +/** + * Which integrations the settings shells offer: the addon flags behind each + * integration section, whether any of them is on (the Integrations tab only shows + * then), and whether this is a managed install. With `reloadAddons` the addon list + * is fetched again on mount, as the integrations tab on both shells does. + */ +export function useIntegrationGates({ reloadAddons }: { reloadAddons: boolean }) { + const { isEnabled: addonEnabled, loadAddons } = useAddonStore(); + const managed = useAuthStore((s) => s.managed); + + const memoriesEnabled = addonEnabled('memories'); + const mcpEnabled = addonEnabled('mcp'); + const airtrailEnabled = addonEnabled('airtrail'); + const llmEnabled = addonEnabled('llm_parsing'); + const dawarichEnabled = addonEnabled('dawarich'); + const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled || dawarichEnabled; + + useEffect(() => { + if (reloadAddons) loadAddons(); + }, [loadAddons, reloadAddons]); + + return { + memoriesEnabled, + mcpEnabled, + airtrailEnabled, + llmEnabled, + dawarichEnabled, + hasIntegrations, + managed, + loadAddons, + }; +} diff --git a/client/src/mobile/screens/settings/MSettings.tsx b/client/src/mobile/screens/settings/MSettings.tsx index 82753884a..a2c396802 100644 --- a/client/src/mobile/screens/settings/MSettings.tsx +++ b/client/src/mobile/screens/settings/MSettings.tsx @@ -17,8 +17,6 @@ import { import type { LucideIcon } from 'lucide-react' import { useTranslation } from '../../../i18n' import { useSettings } from '../../../pages/settings/useSettings' -import { useAuthStore } from '../../../store/authStore' -import { usePluginStore } from '../../../store/pluginStore' import MSettingsPlugins from './MSettingsPlugins' import MSettingsOffline from './MSettingsOffline' import MSettingsGeneral from './MSettingsGeneral' @@ -44,9 +42,7 @@ interface SectionTab { export default function MSettings() { const { t } = useTranslation() const navigate = useNavigate() - const { hasIntegrations, appVersion, activeTab, setActiveTab } = useSettings() - const managed = useAuthStore((s) => s.managed) - const hasPlugins = usePluginStore((s) => s.plugins.length > 0) + const { hasIntegrations, hasPlugins, appVersion, activeTab, setActiveTab, managed } = useSettings() const [dropOpen, setDropOpen] = useState(false) const tabs: SectionTab[] = [ diff --git a/client/src/mobile/screens/settings/MSettingsIntegrations.tsx b/client/src/mobile/screens/settings/MSettingsIntegrations.tsx index 208845baa..f35f204e1 100644 --- a/client/src/mobile/screens/settings/MSettingsIntegrations.tsx +++ b/client/src/mobile/screens/settings/MSettingsIntegrations.tsx @@ -1,11 +1,9 @@ -import { useEffect } from 'react' -import { useAddonStore } from '../../../store/addonStore' import MPhotoProvidersSection from './MPhotoProvidersSection' import MAirTrailConnectionSection from './MAirTrailConnectionSection' import MDawarichConnectionSection from './MDawarichConnectionSection' import MLlmConnectionSection from './MLlmConnectionSection' import MSettingsMcp from './MSettingsMcp' -import { useAuthStore } from '../../../store/authStore' +import { useIntegrationGates } from '../../../components/Settings/useIntegrationGates' /** * "Integrations" section. The photo-provider / AirTrail / LLM connection forms @@ -14,16 +12,7 @@ import { useAuthStore } from '../../../store/authStore' * function audit is rebuilt natively in the mobile design language. */ export default function MSettingsIntegrations() { - const { isEnabled: addonEnabled, loadAddons } = useAddonStore() - const mcpEnabled = addonEnabled('mcp') - const airtrailEnabled = addonEnabled('airtrail') - const llmEnabled = addonEnabled('llm_parsing') - const dawarichEnabled = addonEnabled('dawarich') - const managed = useAuthStore((s) => s.managed) - - useEffect(() => { - loadAddons() - }, [loadAddons]) + const { mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, managed } = useIntegrationGates({ reloadAddons: true }) return ( <> diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index a4d646042..464cf190d 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -15,7 +15,6 @@ import HelpAnchor from '../components/Help/HelpAnchor' import { getHelpContext } from '../help/registry' import OfflineTab from '../components/Settings/OfflineTab' import PluginSettingsTab from '../components/Settings/PluginSettingsTab' -import { usePluginStore } from '../store/pluginStore' import { useSettings } from './settings/useSettings' export default function SettingsPage(): React.ReactElement { @@ -27,8 +26,7 @@ export default function SettingsPage(): React.ReactElement { function SettingsPageDesktop(): React.ReactElement { const { t } = useTranslation() // Page = wiring container: addon/version loading + active-tab state in the hook. - const { hasIntegrations, appVersion, activeTab, setActiveTab, managed } = useSettings() - const hasPlugins = usePluginStore(s => s.plugins.length > 0) + const { hasIntegrations, hasPlugins, appVersion, activeTab, setActiveTab, managed } = useSettings() // Every tab is its own help screen; a tab without one falls back to the settings overview. const helpId = getHelpContext(`settings-${activeTab}`) ? `settings-${activeTab}` : 'settings' diff --git a/client/src/pages/settings/useSettings.ts b/client/src/pages/settings/useSettings.ts index d31403e31..4cb10fbaa 100644 --- a/client/src/pages/settings/useSettings.ts +++ b/client/src/pages/settings/useSettings.ts @@ -1,8 +1,8 @@ import { useState, useEffect } from 'react' import { useSearchParams } from 'react-router' import { authApi } from '../../api/client' -import { useAddonStore } from '../../store/addonStore' -import { useAuthStore } from '../../store/authStore' +import { useIntegrationGates } from '../../components/Settings/useIntegrationGates' +import { usePluginStore } from '../../store/pluginStore' /** * Settings page logic — loads addons + the app version, tracks the active tab @@ -13,15 +13,8 @@ import { useAuthStore } from '../../store/authStore' */ export function useSettings() { const [searchParams] = useSearchParams() - const { isEnabled: addonEnabled, loadAddons } = useAddonStore() - const managed = useAuthStore(s => s.managed) - - const memoriesEnabled = addonEnabled('memories') - const mcpEnabled = addonEnabled('mcp') - const airtrailEnabled = addonEnabled('airtrail') - const llmEnabled = addonEnabled('llm_parsing') - const dawarichEnabled = addonEnabled('dawarich') - const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled || dawarichEnabled + const { hasIntegrations, managed, loadAddons } = useIntegrationGates({ reloadAddons: false }) + const hasPlugins = usePluginStore(s => s.plugins.length > 0) const [appVersion, setAppVersion] = useState(null) const [activeTab, setActiveTab] = useState('display') @@ -46,5 +39,5 @@ export function useSettings() { if (tab) setActiveTab(tab) }, [searchParams]) - return { hasIntegrations, appVersion, activeTab, setActiveTab, managed } + return { hasIntegrations, hasPlugins, appVersion, activeTab, setActiveTab, managed } }