From 3b4539f6007654ba52ae2105dad79c1e13087e08 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:39:01 +0200 Subject: [PATCH] refactor(settings): share the integration and plugin tab gating between desktop and phone The addon flags, the managed install flag and the addon reload of the integrations tab now come from one useIntegrationGates hook, and useSettings hands both shells the plugins tab flag next to the integrations one. --- .../components/Settings/IntegrationsTab.tsx | 19 ++----- .../Settings/useIntegrationGates.test.ts | 55 +++++++++++++++++++ .../Settings/useIntegrationGates.ts | 36 ++++++++++++ .../src/mobile/screens/settings/MSettings.tsx | 6 +- .../settings/MSettingsIntegrations.tsx | 15 +---- client/src/pages/SettingsPage.tsx | 4 +- client/src/pages/settings/useSettings.ts | 17 ++---- 7 files changed, 105 insertions(+), 47 deletions(-) create mode 100644 client/src/components/Settings/useIntegrationGates.test.ts create mode 100644 client/src/components/Settings/useIntegrationGates.ts 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 } }