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.
This commit is contained in:
Maurice
2026-10-09 10:39:01 +02:00
parent deff306755
commit 3b4539f600
7 changed files with 105 additions and 47 deletions
@@ -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<typeof useIntegrations>
@@ -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);
});
});
@@ -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,
};
}
@@ -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[] = [
@@ -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 (
<>
+1 -3
View File
@@ -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'
+5 -12
View File
@@ -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<string | null>(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 }
}