From 79fd2ac6cdedfeab98c3aaf891718d78a0b98a65 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:38:49 +0100 Subject: [PATCH] A connector's page is the one home for its account, knowledge and tools - Tools from a connection turn on and off on the connector's page (a real switch, not a badge), next to their action permissions. - Knowledge synced from a connection lists its sync state there, with Sync now (connector sources and S3/Reddit sources each through their own endpoint). - On the Tools and Knowledge pages, anything from a connection has "Manage connection" in its menu, which opens that page; the tool editor with its second credentials form is no longer offered for them. - A link to a part (Jira & Confluence) opens its parent's page. --- docs/content/Guides/Connectors.mdx | 2 +- .../src/connectors/ConnectionDrawer.test.tsx | 198 ++++++++++++++++++ frontend/src/connectors/ConnectionDrawer.tsx | 107 +++++++++- frontend/src/locale/de.json | 10 +- frontend/src/locale/en.json | 10 +- frontend/src/locale/es.json | 10 +- frontend/src/locale/jp.json | 10 +- frontend/src/locale/ru.json | 10 +- frontend/src/locale/zh-TW.json | 10 +- frontend/src/locale/zh.json | 10 +- frontend/src/settings/Connectors.tsx | 8 +- frontend/src/settings/Sources.tsx | 20 ++ frontend/src/settings/Tools.test.tsx | 151 +++++++++++++ frontend/src/settings/Tools.tsx | 34 ++- 14 files changed, 559 insertions(+), 31 deletions(-) create mode 100644 frontend/src/connectors/ConnectionDrawer.test.tsx create mode 100644 frontend/src/settings/Tools.test.tsx diff --git a/docs/content/Guides/Connectors.mdx b/docs/content/Guides/Connectors.mdx index 5cd658c8..295fda27 100644 --- a/docs/content/Guides/Connectors.mdx +++ b/docs/content/Guides/Connectors.mdx @@ -22,7 +22,7 @@ Open **Settings > Connectors**. Filters narrow the list by category, and **Conne **Knowledge** (formerly Sources) is what the assistant searches: uploads, links, and content synced from your connections. The **Add knowledge** and **Add Tool** dialogs, the composer's Knowledge and Tools menus, and the agent builder all start the same flow, and list tools grouped by the connection they use. A service offered two ways appears as one card: Confluence syncs pages into Knowledge and, through **Jira & Confluence**, lets agents search and update Jira and Confluence. -Click a connected card to see its account, the sources and tools that use it, and to **Reconnect** or **Disconnect**. Disconnecting keeps a source's indexed content but stops its sync. +A connector's page is the one place to manage it: its accounts (**Reconnect**, **Disconnect**), the Knowledge it syncs (**Sync now**), and its tools (on or off, and what each action may do). On the Knowledge and Tools pages, anything from a connection has **Manage connection** in its menu, which opens that page. Disconnecting keeps synced content but stops its sync. ### When a connection needs attention diff --git a/frontend/src/connectors/ConnectionDrawer.test.tsx b/frontend/src/connectors/ConnectionDrawer.test.tsx new file mode 100644 index 00000000..f926a3fc --- /dev/null +++ b/frontend/src/connectors/ConnectionDrawer.test.tsx @@ -0,0 +1,198 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Provider } from 'react-redux'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const connectors = vi.hoisted(() => ({ + getConnection: vi.fn(), + getCatalog: vi.fn(async () => ({ success: true, connectors: [] })), + listConnections: vi.fn(async () => ({ success: true, connections: [] })), +})); +vi.mock('../api/services/connectorsService', () => ({ default: connectors })); + +const users = vi.hoisted(() => ({ + updateToolStatus: vi.fn(), + syncConnector: vi.fn(), + syncSource: vi.fn(), +})); +vi.mock('../api/services/userService', () => ({ default: users })); + +import actionToastReducer, { + selectActionToast, +} from '../notifications/actionToastSlice'; +import ConnectionDrawer from './ConnectionDrawer'; +import connectorsReducer from './connectorsSlice'; +import type { ConnectorDefinition } from './types'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const DRIVE = { + key: 'google_drive', + name: 'Google Drive', + description: 'Sync files.', + icon: 'drive', + publisher: 'built_in', + auth_kind: 'oauth', + capabilities: ['sync'], + setup: { tools: 'off', sync: 'ask' }, + sync_ingestor: 'google_drive', + available: true, + needs_setup: false, + missing_settings: [], +} as unknown as ConnectorDefinition; + +const DETAIL = { + id: 'conn-1', + connector_key: 'google_drive', + auth_kind: 'oauth', + account_label: 'lena@example.com', + status: 'connected', + last_error: null, + server_url: null, + sources: [ + { + id: 'src-1', + name: 'Handbook', + type: 'connector:file', + last_sync: null, + sync_frequency: 'weekly', + sync_state: 'active', + }, + ], + tools: [ + { + id: 'tool-1', + name: 'mcp_tool', + display_name: 'Drive search', + status: true, + credential_mode: 'owner', + actions: [], + }, + ], +}; + +describe('ConnectionDrawer', () => { + let container: HTMLDivElement; + let root: Root; + let store: ReturnType; + const makeStore = () => + configureStore({ + reducer: { + connectors: connectorsReducer, + actionToast: actionToastReducer, + preference: (state = { token: null }) => state, + }, + preloadedState: { + connectors: { + enabled: true, + loading: false, + loaded: true, + failed: false, + catalog: [DRIVE], + connections: [ + { + id: 'conn-1', + connector_key: 'google_drive', + status: 'connected', + }, + ], + }, + preference: { token: null }, + }, + } as Parameters[0]); + + beforeEach(() => { + Object.values(users).forEach((fn) => fn.mockReset()); + connectors.getConnection.mockResolvedValue({ + success: true, + connection: DETAIL, + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const render = async () => { + store = makeStore(); + await act(async () => { + root.render( + + + , + ); + }); + }; + + const button = (text: string) => + Array.from( + document.body.querySelectorAll('button'), + ).find((b) => b.textContent === text)!; + + it('turns a tool on and off from its connection page', async () => { + users.updateToolStatus.mockResolvedValue({ ok: true }); + await render(); + const toggle = document.body.querySelector( + '[aria-label="settings.connectors.detail.toolSwitch"]', + )!; + expect(toggle.getAttribute('aria-checked')).toBe('true'); + await act(async () => toggle.click()); + expect(users.updateToolStatus).toHaveBeenCalledWith( + { id: 'tool-1', status: false }, + null, + ); + expect(toggle.getAttribute('aria-checked')).toBe('false'); + }); + + it('puts the switch back when the change fails', async () => { + users.updateToolStatus.mockResolvedValue({ ok: false }); + await render(); + const toggle = document.body.querySelector( + '[aria-label="settings.connectors.detail.toolSwitch"]', + )!; + await act(async () => toggle.click()); + expect(toggle.getAttribute('aria-checked')).toBe('true'); + expect( + selectActionToast( + store.getState() as Parameters[0], + )?.variant, + ).toBe('destructive'); + }); + + it('syncs a source now through its connection', async () => { + users.syncConnector.mockResolvedValue({ + json: async () => ({ success: true }), + }); + await render(); + await act(async () => button('settings.connectors.detail.syncNow').click()); + expect(users.syncConnector).toHaveBeenCalledWith('src-1', null); + expect(users.syncSource).not.toHaveBeenCalled(); + expect( + selectActionToast( + store.getState() as Parameters[0], + )?.variant, + ).toBe('success'); + }); + + it('labels synced content as Knowledge from this connection', async () => { + await render(); + expect(document.body.textContent).toContain( + 'settings.connectors.detail.sources', + ); + expect(document.body.textContent).not.toContain( + 'settings.connectors.publisher', + ); + }); +}); diff --git a/frontend/src/connectors/ConnectionDrawer.tsx b/frontend/src/connectors/ConnectionDrawer.tsx index ffc9d510..5fdbf882 100644 --- a/frontend/src/connectors/ConnectionDrawer.tsx +++ b/frontend/src/connectors/ConnectionDrawer.tsx @@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import connectorsService from '../api/services/connectorsService'; +import userService from '../api/services/userService'; import { Alert, AlertDescription } from '../components/ui/alert'; import { Badge } from '../components/ui/badge'; import { Button } from '../components/ui/button'; @@ -23,6 +24,7 @@ import { ListRow, ListRows } from '../components/ui/list-row'; import { LoadingState } from '../components/ui/loading-state'; import { Modal, ModalActions } from '../components/ui/modal'; import { SectionHeader } from '../components/ui/section-header'; +import { Switch } from '../components/ui/switch'; import { Sheet, SheetContent, @@ -43,6 +45,8 @@ import { connectorDescription, connectorName } from './i18n'; import ToolPermissions from './ToolPermissions'; import type { ConnectionDetail, + ConnectionSource, + ConnectionTool, ConnectionStatus, ConnectorDefinition, } from './types'; @@ -187,6 +191,33 @@ function RemoveConnectionModal({ ); } +/** A connection tool's on/off switch: the one place it is turned on or off. */ +function ToolSwitch({ + tool, + onToggle, +}: { + tool: ConnectionTool; + onToggle: (toolId: string, on: boolean) => Promise; +}) { + const { t } = useTranslation(); + const [on, setOn] = useState(tool.status); + useEffect(() => setOn(tool.status), [tool.status]); + return ( + { + const next = checked === true; + setOn(next); + if (!(await onToggle(tool.id, next))) setOn(!next); + }} + /> + ); +} + function AccountSection({ connector, detail, @@ -195,6 +226,8 @@ function AccountSection({ onRemove, onSyncMore, onRefreshTools, + onToggleTool, + onSyncNow, }: { connector: ConnectorDefinition; detail: ConnectionDetail; @@ -203,6 +236,9 @@ function AccountSection({ onRemove: (detail: ConnectionDetail) => void; onSyncMore: (detail: ConnectionDetail) => void; onRefreshTools: (detail: ConnectionDetail) => Promise; + /** Turns a tool of this connection on or off for agents and chat. */ + onToggleTool: (toolId: string, on: boolean) => Promise; + onSyncNow: (source: ConnectionSource) => void; }) { const { t } = useTranslation(); const accountTitle = useAccountTitle(); @@ -313,7 +349,17 @@ function AccountSection({ {t('settings.connectors.detail.paused')} - ) : undefined + ) : ( + + ) } /> ))} @@ -353,13 +399,7 @@ function AccountSection({ as="h4" size="xs" title={tool.display_name} - actions={ - - {tool.status - ? t('settings.connectors.detail.toolOn') - : t('settings.connectors.detail.toolOff')} - - } + actions={} /> { + try { + const response = await userService.updateToolStatus( + { id: toolId, status: on }, + token, + ); + if (!response.ok) throw new Error('toggle failed'); + return true; + } catch { + dispatch( + showActionToast({ + variant: 'destructive', + message: t('settings.connectors.detail.toolSwitchFailed'), + }), + ); + return false; + } + }; + + const syncNow = async (source: ConnectionSource) => { + try { + // Drive, SharePoint and Confluence sources sync through the connector + // endpoint; S3 and Reddit through the remote-source one. + const response = source.type?.startsWith('connector') + ? await userService.syncConnector(source.id, token) + : await userService.syncSource({ source_id: source.id }, token); + const data = await response.json(); + if (!data?.success) throw new Error('sync failed'); + dispatch( + showActionToast({ + variant: 'success', + message: t('settings.connectors.detail.syncStarted', { + name: source.name, + interpolation: { escapeValue: false }, + }), + }), + ); + } catch { + dispatch( + showActionToast({ + variant: 'destructive', + message: t('settings.connectors.detail.syncFailed'), + }), + ); + } + }; + const refreshTools = async (detail: ConnectionDetail) => { const data = await connectorsService.refreshTools(detail.id, token); dispatch( @@ -617,6 +704,8 @@ export default function ConnectionDrawer({ }) } onRefreshTools={refreshTools} + onToggleTool={toggleTool} + onSyncNow={syncNow} /> ))} @@ -660,6 +749,8 @@ export default function ConnectionDrawer({ onConnect(part, { mode: 'sync', connectionId: d.id }) } onRefreshTools={refreshTools} + onToggleTool={toggleTool} + onSyncNow={syncNow} /> ))} diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 7c8970a5..d63a70ea 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -1184,7 +1184,12 @@ "noSources": "Aus dieser Verbindung wurde noch nichts synchronisiert.", "remove": "Entfernen", "refreshed": "Werkzeuge aktualisiert: {{added}} hinzugefügt, {{removed}} entfernt.", - "refreshFailed": "Die Werkzeuge konnten nicht aktualisiert werden." + "refreshFailed": "Die Werkzeuge konnten nicht aktualisiert werden.", + "syncNow": "Jetzt synchronisieren", + "syncStarted": "{{name}} wird synchronisiert…", + "syncFailed": "Die Synchronisierung konnte nicht gestartet werden. Versuche es erneut.", + "toolSwitch": "{{name}} an", + "toolSwitchFailed": "Das Werkzeug konnte nicht geändert werden. Versuche es erneut." }, "permission": { "always": "Immer erlauben", @@ -1292,7 +1297,8 @@ "sync": "Synchronisiert ins Wissen", "read": "Schlägt nach", "write": "Führt Aktionen aus" - } + }, + "manageConnection": "Verbindung verwalten" } }, "modals": { diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 53ea673c..01cead74 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -1190,7 +1190,12 @@ "noSources": "Nothing synced from this connection yet.", "remove": "Remove", "refreshed": "Tools refreshed: {{added}} added, {{removed}} removed.", - "refreshFailed": "Could not refresh the tools." + "refreshFailed": "Could not refresh the tools.", + "syncNow": "Sync now", + "syncStarted": "Syncing {{name}}…", + "syncFailed": "Could not start the sync. Try again.", + "toolSwitch": "{{name}} on", + "toolSwitchFailed": "Could not change the tool. Try again." }, "permission": { "always": "Always allow", @@ -1298,7 +1303,8 @@ "sync": "Syncs into Knowledge", "read": "Looks things up", "write": "Takes actions" - } + }, + "manageConnection": "Manage connection" } }, "modals": { diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 37cc4b8b..e3881d24 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -1184,7 +1184,12 @@ "noSources": "Aún no se ha sincronizado nada desde esta conexión.", "remove": "Eliminar", "refreshed": "Herramientas actualizadas: {{added}} añadidas, {{removed}} eliminadas.", - "refreshFailed": "No se pudieron actualizar las herramientas." + "refreshFailed": "No se pudieron actualizar las herramientas.", + "syncNow": "Sincronizar ahora", + "syncStarted": "Sincronizando {{name}}…", + "syncFailed": "No se pudo iniciar la sincronización. Inténtalo de nuevo.", + "toolSwitch": "{{name}} activado", + "toolSwitchFailed": "No se pudo cambiar la herramienta. Inténtalo de nuevo." }, "permission": { "always": "Permitir siempre", @@ -1292,7 +1297,8 @@ "sync": "Sincroniza en Conocimiento", "read": "Consulta datos", "write": "Realiza acciones" - } + }, + "manageConnection": "Gestionar conexión" } }, "modals": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index e4fd81f4..d50646b5 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1175,7 +1175,12 @@ "noSources": "この接続からはまだ何も同期されていません。", "remove": "削除", "refreshed": "ツールを更新しました: {{added}} 件追加、{{removed}} 件削除。", - "refreshFailed": "ツールを更新できませんでした。" + "refreshFailed": "ツールを更新できませんでした。", + "syncNow": "今すぐ同期", + "syncStarted": "{{name}} を同期しています…", + "syncFailed": "同期を開始できませんでした。もう一度お試しください。", + "toolSwitch": "{{name}} をオン", + "toolSwitchFailed": "ツールを変更できませんでした。もう一度お試しください。" }, "permission": { "always": "常に許可", @@ -1276,7 +1281,8 @@ "sync": "ナレッジに同期", "read": "情報を調べる", "write": "操作を行う" - } + }, + "manageConnection": "接続を管理" } }, "modals": { diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index f50b79cc..ee7d2d84 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1240,7 +1240,12 @@ "noSources": "Из этого подключения пока ничего не синхронизировано.", "remove": "Удалить", "refreshed": "Инструменты обновлены: добавлено {{added}}, удалено {{removed}}.", - "refreshFailed": "Не удалось обновить инструменты." + "refreshFailed": "Не удалось обновить инструменты.", + "syncNow": "Синхронизировать", + "syncStarted": "Синхронизация {{name}}…", + "syncFailed": "Не удалось запустить синхронизацию. Попробуйте ещё раз.", + "toolSwitch": "{{name}} включён", + "toolSwitchFailed": "Не удалось изменить инструмент. Попробуйте ещё раз." }, "permission": { "always": "Всегда разрешать", @@ -1362,7 +1367,8 @@ "sync": "Синхронизирует в знания", "read": "Ищет информацию", "write": "Выполняет действия" - } + }, + "manageConnection": "Управлять подключением" } }, "modals": { diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 23cb4023..b178dfe4 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1175,7 +1175,12 @@ "noSources": "尚未從此連線同步任何內容。", "remove": "移除", "refreshed": "工具已重新整理:新增 {{added}} 個,移除 {{removed}} 個。", - "refreshFailed": "無法重新整理工具。" + "refreshFailed": "無法重新整理工具。", + "syncNow": "立即同步", + "syncStarted": "正在同步 {{name}}…", + "syncFailed": "無法開始同步。請再試一次。", + "toolSwitch": "{{name}} 開啟", + "toolSwitchFailed": "無法變更工具。請再試一次。" }, "permission": { "always": "一律允許", @@ -1276,7 +1281,8 @@ "sync": "同步到知識庫", "read": "查詢資訊", "write": "執行操作" - } + }, + "manageConnection": "管理連線" } }, "modals": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index a6c82d8b..a43b1e41 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1175,7 +1175,12 @@ "noSources": "尚未从此连接同步任何内容。", "remove": "移除", "refreshed": "工具已刷新:新增 {{added}} 个,移除 {{removed}} 个。", - "refreshFailed": "无法刷新工具。" + "refreshFailed": "无法刷新工具。", + "syncNow": "立即同步", + "syncStarted": "正在同步 {{name}}…", + "syncFailed": "无法开始同步。请重试。", + "toolSwitch": "{{name}} 开启", + "toolSwitchFailed": "无法更改工具。请重试。" }, "permission": { "always": "始终允许", @@ -1276,7 +1281,8 @@ "sync": "同步到知识库", "read": "查找信息", "write": "执行操作" - } + }, + "manageConnection": "管理连接" } }, "modals": { diff --git a/frontend/src/settings/Connectors.tsx b/frontend/src/settings/Connectors.tsx index 1a776fb3..b6c8c99c 100644 --- a/frontend/src/settings/Connectors.tsx +++ b/frontend/src/settings/Connectors.tsx @@ -88,7 +88,13 @@ export default function Connectors() { }, [dispatch, token]); const custom = catalog.filter((c) => c.publisher === 'custom'); - const openConnector = catalog.find((c) => c.key === openKey) ?? null; + // A link to a part (a tool's "Manage connection") opens its parent's page. + const openTarget = catalog.find((c) => c.key === openKey); + const openConnector = + (openTarget?.part_of && + catalog.find((c) => c.key === openTarget.part_of)) || + openTarget || + null; // One service offered two ways (Confluence sync and the Jira & Confluence // MCP actions) is one card. A part is shown on its own only when its diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx index 141a1d4e..65c28c57 100644 --- a/frontend/src/settings/Sources.tsx +++ b/frontend/src/settings/Sources.tsx @@ -5,6 +5,7 @@ import { Eye, HardDrive, Network, + Plug, RefreshCw, Search, SlidersHorizontal, @@ -14,6 +15,7 @@ import { import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; import userService from '../api/services/userService'; import modelService from '../api/services/modelService'; @@ -90,6 +92,7 @@ export default function Sources({ handleDeleteDocument, }: DocumentsProps) { const { t } = useTranslation(); + const navigate = useNavigate(); const dispatch = useDispatch(); const token = useSelector(selectToken); const uploadTasks = useSelector(selectUploadTasks); @@ -341,6 +344,23 @@ export default function Sources({ }, ]; + // Synced from a connection: its account, sync and other uses live on + // the connector's page. + const connection = document.connectionId + ? connections.find((c) => c.id === document.connectionId) + : undefined; + if (connection) { + actions.push({ + icon: Plug, + label: t('settings.connectors.manageConnection'), + onClick: () => + navigate( + `/settings/connectors?connector=${encodeURIComponent(connection.connector_key)}`, + ), + variant: 'default', + }); + } + if (document.ingestStatus === 'failed') { actions.push({ icon: RefreshCw, diff --git a/frontend/src/settings/Tools.test.tsx b/frontend/src/settings/Tools.test.tsx new file mode 100644 index 00000000..63db1694 --- /dev/null +++ b/frontend/src/settings/Tools.test.tsx @@ -0,0 +1,151 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const TOOLS = [ + { + id: 'tg', + name: 'telegram', + displayName: 'Telegram', + customName: '', + description: 'Send messages', + status: true, + config: {}, + actions: [], + connection_id: 'conn-1', + }, + { + id: 'api', + name: 'api_tool', + displayName: 'API Tool', + customName: 'My API', + description: 'Calls an API', + status: true, + config: {}, + actions: [], + connection_id: null, + }, +]; + +vi.mock('../api/services/userService', () => ({ + default: { + getUserTools: vi.fn(async () => ({ json: async () => ({ tools: TOOLS }) })), + getMCPAuthStatus: vi.fn(async () => ({ + json: async () => ({ statuses: {} }), + })), + getAvailableTools: vi.fn(async () => ({ + json: async () => ({ data: [] }), + })), + }, +})); +vi.mock('../api/services/connectorsService', () => ({ + default: { + getCatalog: vi.fn(async () => ({ success: true, connectors: [] })), + listConnections: vi.fn(async () => ({ + success: true, + connections: [ + { + id: 'conn-1', + connector_key: 'telegram', + name: 'Telegram', + icon: 'tool_telegram', + status: 'connected', + account_label: '…abcd', + }, + ], + })), + }, +})); + +import connectorsReducer from '../connectors/connectorsSlice'; +import notificationsReducer from '../notifications/notificationsSlice'; +import Tools from './Tools'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +function Where() { + const location = useLocation(); + return
{location.pathname + location.search}
; +} + +describe('Tools page', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const render = async () => { + const store = configureStore({ + reducer: { + connectors: connectorsReducer, + notifications: notificationsReducer, + preference: (state = { token: null }) => state, + }, + }); + await act(async () => { + root.render( + + + + } /> + } /> + + + , + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + }); + }; + + const openMenu = async (index: number) => { + const triggers = container.querySelectorAll( + '[aria-label="settings.tools.settingsIconAlt"]', + ); + await act(async () => { + triggers[index].dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0 }), + ); + }); + }; + + const menuItem = (text: string) => + Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.textContent?.includes(text)); + + it('sends a connected tool to its connector page to manage it', async () => { + await render(); + await openMenu(0); + expect(menuItem('settings.tools.edit')).toBeUndefined(); + await act(async () => + menuItem('settings.connectors.manageConnection')!.click(), + ); + expect( + document.body.querySelector('[data-testid="where"]')?.textContent, + ).toBe('/settings/connectors?connector=telegram'); + }); + + it('keeps Edit for a tool that is not from a connection', async () => { + await render(); + await openMenu(1); + expect(menuItem('settings.tools.edit')).toBeDefined(); + expect(menuItem('settings.connectors.manageConnection')).toBeUndefined(); + }); +}); diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx index 841fb836..6d6c42b4 100644 --- a/frontend/src/settings/Tools.tsx +++ b/frontend/src/settings/Tools.tsx @@ -1,4 +1,4 @@ -import { Pencil, RefreshCw, Trash2, Users } from 'lucide-react'; +import { Pencil, Plug, RefreshCw, Trash2, Users } from 'lucide-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; @@ -148,12 +148,19 @@ export default function Tools() { const getMenuOptions = (tool: UserToolType): MenuOption[] => { const options: MenuOption[] = [ - { - icon: Pencil, - label: t('settings.tools.edit'), - onClick: () => handleSettingsClick(tool), - variant: 'default', - }, + connectionOf(tool) + ? { + icon: Plug, + label: t('settings.connectors.manageConnection'), + onClick: () => handleSettingsClick(tool), + variant: 'default', + } + : { + icon: Pencil, + label: t('settings.tools.edit'), + onClick: () => handleSettingsClick(tool), + variant: 'default', + }, { icon: Trash2, label: t('settings.tools.delete'), @@ -234,7 +241,20 @@ export default function Tools() { }); }; + // A connected tool is managed on its connector's page (account, on/off, + // permissions); only other tools open the tool editor here. + const connectionOf = (tool: UserToolType) => + tool.connection_id + ? connections.find((c) => c.id === tool.connection_id) + : undefined; const handleSettingsClick = (tool: UserToolType) => { + const connection = connectionOf(tool); + if (connection) { + navigate( + `/settings/connectors?connector=${encodeURIComponent(connection.connector_key)}`, + ); + return; + } setSelectedTool(tool); };