mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 16:15:14 +00:00
Manage a connected tool on its connector page
A tool from a connection shows the catalog's description and a Manage connection button instead of its own switch, and its menu no longer opens the raw MCP server form to reconnect. It says when it is off or needs signing in again.
This commit is contained in:
1 parent
cc7b07817f
commit
97e37a7e37
3 files changed
+216
-117
No files matched your search
@@ -86,12 +86,15 @@ export const selectConnectorCatalog = (state: RootLike) =>
|
||||
state.connectors.catalog;
|
||||
export const selectConnections = (state: RootLike) =>
|
||||
state.connectors.connections;
|
||||
/** Whether a connection needs the user to sign in again (expired or failing). */
|
||||
export const connectionNeedsSignIn = (
|
||||
connection: { status: string } | null | undefined,
|
||||
) =>
|
||||
connection?.status === 'reconnect_needed' || connection?.status === 'error';
|
||||
|
||||
/** A connection that needs the user (signing in again, or failing). */
|
||||
export const selectConnectionsNeedAttention = (state: RootLike) =>
|
||||
(state.connectors?.connections ?? []).some(
|
||||
(connection) =>
|
||||
connection.status === 'reconnect_needed' || connection.status === 'error',
|
||||
);
|
||||
(state.connectors?.connections ?? []).some(connectionNeedsSignIn);
|
||||
export const selectConnectorsLoading = (state: RootLike) =>
|
||||
state.connectors.loading;
|
||||
export const selectConnectorsLoaded = (state: RootLike) =>
|
||||
|
||||
@@ -31,6 +31,17 @@ const TOOLS = [
|
||||
actions: [],
|
||||
connection_id: null,
|
||||
},
|
||||
{
|
||||
id: 'lin',
|
||||
name: 'mcp_tool',
|
||||
displayName: 'Linear',
|
||||
customName: '',
|
||||
description: 'MCP Server: https://mcp.linear.app/mcp',
|
||||
status: true,
|
||||
config: {},
|
||||
actions: [],
|
||||
connection_id: 'conn-2',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
@@ -46,7 +57,27 @@ vi.mock('../api/services/userService', () => ({
|
||||
}));
|
||||
vi.mock('../api/services/connectorsService', () => ({
|
||||
default: {
|
||||
getCatalog: vi.fn(async () => ({ success: true, connectors: [] })),
|
||||
getCatalog: vi.fn(async () => ({
|
||||
success: true,
|
||||
connectors: [
|
||||
{
|
||||
key: 'telegram',
|
||||
name: 'Telegram',
|
||||
description: 'Send messages to a chat.',
|
||||
publisher: 'built_in',
|
||||
available: true,
|
||||
capabilities: ['write'],
|
||||
},
|
||||
{
|
||||
key: 'mcp:linear',
|
||||
name: 'Linear',
|
||||
description: 'Issues and projects.',
|
||||
publisher: 'preset',
|
||||
available: true,
|
||||
capabilities: ['write'],
|
||||
},
|
||||
],
|
||||
})),
|
||||
listConnections: vi.fn(async () => ({
|
||||
success: true,
|
||||
connections: [
|
||||
@@ -58,6 +89,14 @@ vi.mock('../api/services/connectorsService', () => ({
|
||||
status: 'connected',
|
||||
account_label: '…abcd',
|
||||
},
|
||||
{
|
||||
id: 'conn-2',
|
||||
connector_key: 'mcp:linear',
|
||||
name: 'Linear',
|
||||
icon: 'linear',
|
||||
status: 'reconnect_needed',
|
||||
account_label: 'Linear',
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
@@ -142,10 +181,43 @@ describe('Tools page', () => {
|
||||
).toBe('/settings/connectors?connector=telegram');
|
||||
});
|
||||
|
||||
const card = (title: string) =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>('h2'))
|
||||
.find((h) => h.textContent === title)!
|
||||
.closest<HTMLElement>('[data-slot="card"]')!;
|
||||
|
||||
it('manages a connected tool on its connector page, not with its own switch', async () => {
|
||||
await render();
|
||||
const telegram = card('Telegram');
|
||||
expect(telegram.querySelector('[role="switch"]')).toBeNull();
|
||||
// The catalog's plain description, not the tool's setup notes.
|
||||
expect(telegram.textContent).toContain(
|
||||
'settings.connectors.descriptions.telegram',
|
||||
);
|
||||
const manage = Array.from(telegram.querySelectorAll('button')).find(
|
||||
(b) => b.textContent === 'settings.connectors.manageConnection',
|
||||
)!;
|
||||
await act(async () => manage.click());
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="where"]')?.textContent,
|
||||
).toBe('/settings/connectors?connector=telegram');
|
||||
});
|
||||
|
||||
it('says a connected tool needs signing in again, with no raw MCP reconnect', async () => {
|
||||
await render();
|
||||
expect(card('Linear').textContent).toContain(
|
||||
'settings.connectors.health.signInAgain',
|
||||
);
|
||||
expect(card('Linear').textContent).not.toContain('MCP Server:');
|
||||
await openMenu(2);
|
||||
expect(menuItem('settings.tools.reconnect')).toBeUndefined();
|
||||
});
|
||||
|
||||
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();
|
||||
expect(card('My API').querySelector('[role="switch"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+136
-112
@@ -23,10 +23,12 @@ import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu';
|
||||
import { EmptyState } from '../components/ui/empty-state';
|
||||
import ConnectorIcon from '../connectors/ConnectorIcon';
|
||||
import {
|
||||
connectionNeedsSignIn,
|
||||
loadConnectors,
|
||||
selectConnections,
|
||||
selectConnectorCatalog,
|
||||
} from '../connectors/connectorsSlice';
|
||||
import { connectorDescription } from '../connectors/i18n';
|
||||
import { useLoaderState } from '../hooks';
|
||||
import type { AvailableToolType } from '../modals/types';
|
||||
import AddToolModal from '../modals/AddToolModal';
|
||||
@@ -178,7 +180,9 @@ export default function Tools() {
|
||||
variant: 'default',
|
||||
});
|
||||
}
|
||||
if (tool.name === 'mcp_tool') {
|
||||
// A connected server reconnects on its connector page, like any other
|
||||
// connection; only an MCP tool without one keeps the server form.
|
||||
if (tool.name === 'mcp_tool' && !connectionOf(tool)) {
|
||||
options.splice(1, 0, {
|
||||
icon: RefreshCw,
|
||||
label: t('settings.tools.reconnect'),
|
||||
@@ -402,123 +406,143 @@ export default function Tools() {
|
||||
<EmptyState title={t('settings.tools.noToolsFound')} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered.map((tool, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
variant="filled"
|
||||
padding="lg"
|
||||
className="relative h-52 justify-between overflow-hidden"
|
||||
>
|
||||
{!tool.default && (
|
||||
<ActionMenu
|
||||
options={getMenuOptions(tool)}
|
||||
triggerLabel={t('settings.tools.settingsIconAlt')}
|
||||
className="absolute top-3 right-3 z-10"
|
||||
/>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<div className="flex w-full items-center gap-2 px-1">
|
||||
<ToolIcon
|
||||
name={tool.name}
|
||||
title={t('settings.tools.toolIconTitle', {
|
||||
name: tool.displayName,
|
||||
})}
|
||||
className="size-6"
|
||||
{filtered.map((tool, index) => {
|
||||
const connection = connectionOf(tool);
|
||||
const connector = connection
|
||||
? catalog.find(
|
||||
(c) => c.key === connection.connector_key,
|
||||
)
|
||||
: undefined;
|
||||
// A catalog service reads as the catalog describes it;
|
||||
// a custom server keeps the description it came with.
|
||||
const description =
|
||||
connector && connector.publisher !== 'custom'
|
||||
? connectorDescription(t, connector)
|
||||
: tool.description;
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
variant="filled"
|
||||
padding="lg"
|
||||
className="relative h-52 justify-between overflow-hidden"
|
||||
>
|
||||
{!tool.default && (
|
||||
<ActionMenu
|
||||
options={getMenuOptions(tool)}
|
||||
triggerLabel={t('settings.tools.settingsIconAlt')}
|
||||
className="absolute top-3 right-3 z-10"
|
||||
/>
|
||||
{tool.default && (
|
||||
<Badge variant="neutral">
|
||||
{t('settings.tools.builtIn')}
|
||||
</Badge>
|
||||
)}
|
||||
{tool.name === 'mcp_tool' &&
|
||||
mcpStatuses[tool.id] && (
|
||||
<Badge
|
||||
variant={
|
||||
mcpStatuses[tool.id] === 'connected'
|
||||
? 'success'
|
||||
: mcpStatuses[tool.id] === 'needs_auth'
|
||||
? 'warning'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{mcpStatuses[tool.id] === 'connected'
|
||||
? t('settings.tools.authStatus.connected')
|
||||
: mcpStatuses[tool.id] === 'needs_auth'
|
||||
? t('settings.tools.authStatus.needsAuth')
|
||||
: t(
|
||||
'settings.tools.authStatus.configured',
|
||||
)}
|
||||
)}
|
||||
<div className="w-full">
|
||||
<div className="flex w-full items-center gap-2 px-1">
|
||||
<ToolIcon
|
||||
name={tool.name}
|
||||
title={t('settings.tools.toolIconTitle', {
|
||||
name: tool.displayName,
|
||||
})}
|
||||
className="size-6"
|
||||
/>
|
||||
{tool.default && (
|
||||
<Badge variant="neutral">
|
||||
{t('settings.tools.builtIn')}
|
||||
</Badge>
|
||||
)}
|
||||
{tool.ownership === 'team' && (
|
||||
<Badge variant="neutral">
|
||||
<Users className="size-3" aria-hidden="true" />
|
||||
{tool.team_access === 'editor'
|
||||
? t('teamAccess.editor')
|
||||
: t('teamAccess.viewer')}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionNeedsSignIn(connection) ? (
|
||||
<Badge variant="warning">
|
||||
{t('settings.connectors.health.signInAgain')}
|
||||
</Badge>
|
||||
) : !tool.status ? (
|
||||
<Badge variant="neutral">
|
||||
{t('settings.tools.off')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{tool.name === 'mcp_tool' &&
|
||||
!connection &&
|
||||
mcpStatuses[tool.id] && (
|
||||
<Badge
|
||||
variant={
|
||||
mcpStatuses[tool.id] === 'connected'
|
||||
? 'success'
|
||||
: mcpStatuses[tool.id] === 'needs_auth'
|
||||
? 'warning'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{mcpStatuses[tool.id] === 'connected'
|
||||
? t('settings.tools.authStatus.connected')
|
||||
: mcpStatuses[tool.id] === 'needs_auth'
|
||||
? t(
|
||||
'settings.tools.authStatus.needsAuth',
|
||||
)
|
||||
: t(
|
||||
'settings.tools.authStatus.configured',
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{tool.ownership === 'team' && (
|
||||
<Badge variant="neutral">
|
||||
<Users
|
||||
className="size-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{tool.team_access === 'editor'
|
||||
? t('teamAccess.editor')
|
||||
: t('teamAccess.viewer')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-[9px] px-1">
|
||||
<CardTitle
|
||||
as="h2"
|
||||
title={tool.customName || tool.displayName}
|
||||
className="truncate capitalize"
|
||||
>
|
||||
{tool.customName || tool.displayName}
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
size="xs"
|
||||
className="mt-1 line-clamp-4 max-h-24 overflow-hidden break-words"
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-[9px] px-1">
|
||||
<CardTitle
|
||||
as="h2"
|
||||
title={tool.customName || tool.displayName}
|
||||
className="truncate capitalize"
|
||||
>
|
||||
{tool.customName || tool.displayName}
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
size="xs"
|
||||
className="mt-1 line-clamp-4 max-h-24 overflow-hidden break-words"
|
||||
title={tool.description}
|
||||
>
|
||||
{tool.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const connection = tool.connection_id
|
||||
? connections.find(
|
||||
(c) => c.id === tool.connection_id,
|
||||
)
|
||||
: undefined;
|
||||
if (!connection) return null;
|
||||
// The same meta line as a Sources tile; mr-12 keeps
|
||||
// it clear of the status switch.
|
||||
return (
|
||||
{connection && (
|
||||
// The way to the connection's page, marked with
|
||||
// the service it comes from.
|
||||
<CardFooter>
|
||||
<span className="mr-12 flex min-w-0 items-center gap-2">
|
||||
<ConnectorIcon
|
||||
icon={connection.icon}
|
||||
className="size-3.5 shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="truncate"
|
||||
title={connection.account_label}
|
||||
>
|
||||
{t('settings.tools.viaConnection', {
|
||||
name: connection.name,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => handleSettingsClick(tool)}
|
||||
>
|
||||
<ConnectorIcon icon={connection.icon} />
|
||||
{t('settings.connectors.manageConnection')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
);
|
||||
})()}
|
||||
<div className="absolute right-4 bottom-4">
|
||||
<Switch
|
||||
checked={tool.status}
|
||||
onCheckedChange={(checked) =>
|
||||
updateToolStatus(tool.id, checked)
|
||||
}
|
||||
id={`toolToggle-${index}`}
|
||||
aria-label={t('settings.tools.toggleToolAria', {
|
||||
toolName: tool.customName || tool.displayName,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
)}
|
||||
{/* A connected tool is turned on and off on its
|
||||
connection's page, the one home for it. */}
|
||||
{!connection && (
|
||||
<div className="absolute right-4 bottom-4">
|
||||
<Switch
|
||||
checked={tool.status}
|
||||
onCheckedChange={(checked) =>
|
||||
updateToolStatus(tool.id, checked)
|
||||
}
|
||||
id={`toolToggle-${index}`}
|
||||
aria-label={t('settings.tools.toggleToolAria', {
|
||||
toolName: tool.customName || tool.displayName,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
|
||||
Reference in new issue
Block a user