mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 14:14:50 +00:00
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.
This commit is contained in:
1 parent
3b1288097d
commit
79fd2ac6cd
14 files changed
+559
-31
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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<typeof makeStore>;
|
||||
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<typeof configureStore>[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(
|
||||
<Provider store={store}>
|
||||
<ConnectionDrawer
|
||||
connector={DRIVE}
|
||||
onClose={vi.fn()}
|
||||
onConnect={vi.fn()}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const button = (text: string) =>
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'[aria-label="settings.connectors.detail.toolSwitch"]',
|
||||
)!;
|
||||
await act(async () => toggle.click());
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('true');
|
||||
expect(
|
||||
selectActionToast(
|
||||
store.getState() as Parameters<typeof selectActionToast>[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<typeof selectActionToast>[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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [on, setOn] = useState(tool.status);
|
||||
useEffect(() => setOn(tool.status), [tool.status]);
|
||||
return (
|
||||
<Switch
|
||||
checked={on}
|
||||
aria-label={t('settings.connectors.detail.toolSwitch', {
|
||||
name: tool.display_name,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
onCheckedChange={async (checked) => {
|
||||
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<void>;
|
||||
/** Turns a tool of this connection on or off for agents and chat. */
|
||||
onToggleTool: (toolId: string, on: boolean) => Promise<boolean>;
|
||||
onSyncNow: (source: ConnectionSource) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const accountTitle = useAccountTitle();
|
||||
@@ -313,7 +349,17 @@ function AccountSection({
|
||||
<Badge variant="warning">
|
||||
{t('settings.connectors.detail.paused')}
|
||||
</Badge>
|
||||
) : undefined
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => onSyncNow(source)}
|
||||
>
|
||||
{t('settings.connectors.detail.syncNow')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@@ -353,13 +399,7 @@ function AccountSection({
|
||||
as="h4"
|
||||
size="xs"
|
||||
title={tool.display_name}
|
||||
actions={
|
||||
<Badge variant={tool.status ? 'success' : 'neutral'}>
|
||||
{tool.status
|
||||
? t('settings.connectors.detail.toolOn')
|
||||
: t('settings.connectors.detail.toolOff')}
|
||||
</Badge>
|
||||
}
|
||||
actions={<ToolSwitch tool={tool} onToggle={onToggleTool} />}
|
||||
/>
|
||||
<ToolPermissions
|
||||
connectionId={detail.id}
|
||||
@@ -482,6 +522,53 @@ export default function ConnectionDrawer({
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTool = async (toolId: string, on: boolean) => {
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -660,6 +749,8 @@ export default function ConnectionDrawer({
|
||||
onConnect(part, { mode: 'sync', connectionId: d.id })
|
||||
}
|
||||
onRefreshTools={refreshTools}
|
||||
onToggleTool={toggleTool}
|
||||
onSyncNow={syncNow}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AppDispatch>();
|
||||
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,
|
||||
|
||||
@@ -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 <div data-testid="where">{location.pathname + location.search}</div>;
|
||||
}
|
||||
|
||||
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(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={['/settings/tools']}>
|
||||
<Routes>
|
||||
<Route path="/settings/tools" element={<Tools />} />
|
||||
<Route path="/settings/connectors" element={<Where />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
};
|
||||
|
||||
const openMenu = async (index: number) => {
|
||||
const triggers = container.querySelectorAll<HTMLButtonElement>(
|
||||
'[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<HTMLElement>('[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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user