feat(editor): Add shared tools-connection modal (#31381)

Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
Bernhard Wittmann 2026-06-04 10:36:42 +02:00 committed by GitHub
parent afd7ddf372
commit 11dfca24d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 3305 additions and 1 deletions

View File

@ -1,4 +1,6 @@
import { render } from '@testing-library/vue';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import N8nRecycleScroller from './RecycleScroller.vue';
@ -25,5 +27,37 @@ describe('components', () => {
);
expect(wrapper.html()).toMatchSnapshot();
});
it('scrolls to an item by key using cached item positions', async () => {
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight',
);
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
value: itemSize,
});
try {
const wrapper = mount(N8nRecycleScroller, {
props: {
itemSize,
itemKey,
items,
},
});
await nextTick();
wrapper.vm.scrollToKey('10');
expect(wrapper.find('.recycle-scroller-wrapper').element.scrollTop).toBe(1000);
} finally {
if (originalOffsetHeight) {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight);
} else {
Reflect.deleteProperty(HTMLElement.prototype, 'offsetHeight');
}
}
});
});
});

View File

@ -182,6 +182,22 @@ function onScroll() {
scrollTop.value = wrapperRef.value.scrollTop;
}
function scrollToKey(key: Item[Key]) {
if (!wrapperRef.value) {
return;
}
const position = itemPositionCache.value[key];
if (position === undefined) {
return;
}
wrapperRef.value.scrollTop = position;
scrollTop.value = position;
}
defineExpose({ scrollToKey });
</script>
<template>

View File

@ -6531,5 +6531,44 @@
"instanceAi.welcomeModal.gateway.instructions.windows": "Open Terminal (Windows key, type \"Terminal\") and paste the command below.",
"instanceAi.welcomeModal.gateway.instructions.linux": "Open your terminal and paste the command below.",
"instanceAi.welcomeModal.gateway.tokenExpiresIn": "Token expires in {minutes} min",
"instanceAi.welcomeModal.gateway.tokenExpired": "Token has expired. Copy the command again."
"instanceAi.welcomeModal.gateway.tokenExpired": "Token has expired. Copy the command again.",
"tools.connection.title": "Tools",
"tools.connection.search.placeholder": "Search all tools...",
"tools.connection.sections.availableNodes": "Connect to a service",
"tools.connection.sections.availableAgents": "Hand off to an agent",
"tools.connection.sections.availableData": "Read or write data",
"tools.connection.sections.availableWorkflows": "Trigger a workflow",
"tools.connection.action.connect": "Connect",
"tools.connection.action.connected": "Connected",
"tools.connection.action.configure": "Configure",
"tools.connection.action.close": "Close",
"tools.connection.credentialPicker.search": "Search credentials...",
"tools.connection.credentialPicker.create": "Create credential",
"tools.connection.credentialPicker.noResults": "No credentials found",
"tools.connection.tabs.services": "Services",
"tools.connection.tabs.agents": "Agents",
"tools.connection.tabs.data": "Data",
"tools.connection.tabs.workflows": "Workflows",
"tools.connection.tabs.settings": "Settings",
"tools.connection.tabs.details": "Details",
"tools.connection.settings.toolInclusion": "Tool Inclusion",
"tools.connection.settings.inclusion.all": "All",
"tools.connection.settings.inclusion.selected": "Only Selected",
"tools.connection.settings.inclusion.except": "All Except",
"tools.connection.settings.toolsToInclude": "Tools to Include",
"tools.connection.settings.toolsToExclude": "Tools to Exclude",
"tools.connection.settings.toolsPlaceholder": "Select tools...",
"tools.connection.settings.disconnect": "Disconnect",
"tools.connection.settings.save": "Save settings",
"tools.connection.empty.title": "No tools available",
"tools.connection.empty.noResults": "No results for \"{query}\"",
"tools.connection.detail.back": "Back",
"tools.connection.detail.publisher": "Publisher",
"tools.connection.detail.version": "Version",
"tools.connection.detail.moreInfo": "More info",
"tools.connection.detail.docs": "Docs",
"tools.connection.detail.readTools": "Read tools",
"tools.connection.detail.writeTools": "Write tools",
"tools.connection.detail.otherTools": "Tools",
"tools.connection.detail.noAdditionalDetails": "No additional details available"
}

View File

@ -180,5 +180,10 @@ export default defineConfig(
'no-restricted-syntax': 'off',
},
},
{
// Mirrors the `*.stories.ts` exclusion in tsconfig.json — typescript-eslint
// can't parse files outside the TS project.
ignores: ['src/**/*.stories.ts'],
},
...oxlint.buildFromOxlintConfigFile('./.oxlintrc.json'),
);

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue';
import { N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { ToolConnectionItem } from './types';
const props = defineProps<{
item: ToolConnectionItem;
}>();
const i18n = useI18n();
const hasContent = computed(() => Boolean(props.item.longDescription));
</script>
<template>
<div :class="$style.container" data-test-id="tools-connection-default-detail-body">
<p v-if="hasContent" :class="$style.description">
{{ item.longDescription }}
</p>
<div v-else :class="$style.placeholder" data-test-id="tools-connection-detail-placeholder">
<N8nText color="text-light">
{{ i18n.baseText('tools.connection.detail.noAdditionalDetails') }}
</N8nText>
</div>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
}
.description {
margin: 0;
color: var(--color--text);
font-size: var(--font-size--2xs);
line-height: var(--line-height--md);
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing--xl);
min-height: 160px;
}
</style>

View File

@ -0,0 +1,232 @@
<script setup lang="ts">
import { computed } from 'vue';
import { N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { McpServerConnectionItem, McpServerTool } from './types';
const props = defineProps<{
item: McpServerConnectionItem;
}>();
const i18n = useI18n();
const readTools = computed<McpServerTool[]>(() =>
props.item.availableTools.filter((tool) => tool.category === 'read'),
);
const writeTools = computed<McpServerTool[]>(() =>
props.item.availableTools.filter((tool) => tool.category === 'write'),
);
const otherTools = computed<McpServerTool[]>(() =>
props.item.availableTools.filter((tool) => tool.category === undefined),
);
const hasMetadata = computed(
() => Boolean(props.item.publisher) || Boolean(props.item.version) || Boolean(props.item.docsUrl),
);
</script>
<template>
<div :class="$style.container">
<p v-if="item.longDescription" :class="$style.description">
{{ item.longDescription }}
</p>
<div
v-if="hasMetadata"
:class="$style.metadata"
data-test-id="tools-connection-detail-metadata"
>
<div v-if="item.publisher" :class="$style.metadataCell">
<N8nText :class="$style.metadataLabel" size="small" color="text-light" bold>
{{ i18n.baseText('tools.connection.detail.publisher') }}
</N8nText>
<a
v-if="item.publisher.url"
:href="item.publisher.url"
target="_blank"
rel="noopener noreferrer"
:class="$style.metadataLink"
>
{{ item.publisher.name }}
</a>
<N8nText v-else>{{ item.publisher.name }}</N8nText>
</div>
<div v-if="item.version" :class="$style.metadataCell">
<N8nText :class="$style.metadataLabel" size="small" color="text-light" bold>
{{ i18n.baseText('tools.connection.detail.version') }}
</N8nText>
<N8nText>{{ item.version }}</N8nText>
</div>
<div v-if="item.docsUrl" :class="$style.metadataCell">
<N8nText :class="$style.metadataLabel" size="small" color="text-light" bold>
{{ i18n.baseText('tools.connection.detail.moreInfo') }}
</N8nText>
<a
:href="item.docsUrl"
target="_blank"
rel="noopener noreferrer"
:class="$style.metadataLink"
>
{{ i18n.baseText('tools.connection.detail.docs') }}
</a>
</div>
</div>
<div v-if="hasMetadata" :class="$style.divider" />
<section v-if="readTools.length > 0" :class="$style.toolsSection">
<div :class="$style.toolsHeader">
<N8nText :class="$style.toolsLabel" size="small" color="text-light" bold>
{{ i18n.baseText('tools.connection.detail.readTools') }}
</N8nText>
<span :class="$style.toolsCount">{{ readTools.length }}</span>
</div>
<div :class="$style.chipList" data-test-id="tools-connection-detail-read-tools">
<span
v-for="tool in readTools"
:key="tool.id"
:class="$style.chip"
data-test-id="tools-connection-detail-tool"
>
{{ tool.name }}
</span>
</div>
</section>
<section v-if="writeTools.length > 0" :class="$style.toolsSection">
<div :class="$style.toolsHeader">
<N8nText :class="$style.toolsLabel" size="small" color="text-light" bold>
{{ i18n.baseText('tools.connection.detail.writeTools') }}
</N8nText>
<span :class="$style.toolsCount">{{ writeTools.length }}</span>
</div>
<div :class="$style.chipList" data-test-id="tools-connection-detail-write-tools">
<span
v-for="tool in writeTools"
:key="tool.id"
:class="$style.chip"
data-test-id="tools-connection-detail-tool"
>
{{ tool.name }}
</span>
</div>
</section>
<section v-if="otherTools.length > 0" :class="$style.toolsSection">
<div :class="$style.toolsHeader">
<N8nText :class="$style.toolsLabel" size="small" color="text-light" bold>
{{ i18n.baseText('tools.connection.detail.otherTools') }}
</N8nText>
<span :class="$style.toolsCount">{{ otherTools.length }}</span>
</div>
<div :class="$style.chipList" data-test-id="tools-connection-detail-other-tools">
<span
v-for="tool in otherTools"
:key="tool.id"
:class="$style.chip"
data-test-id="tools-connection-detail-tool"
>
{{ tool.name }}
</span>
</div>
</section>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
gap: var(--spacing--md);
}
.description {
margin: 0;
color: var(--color--text);
font-size: var(--font-size--2xs);
line-height: var(--line-height--md);
}
.metadata {
display: flex;
gap: var(--spacing--xl);
}
.metadataCell {
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
min-width: 0;
}
.metadataLabel {
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: var(--font-size--3xs);
}
.metadataLink {
color: var(--color--text);
text-decoration: underline;
font-size: var(--font-size--2xs);
&:hover {
color: var(--color--primary);
}
}
.divider {
height: 1px;
background: var(--color--foreground);
}
.toolsSection {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.toolsHeader {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
}
.toolsLabel {
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 11px;
}
.toolsCount {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 18px;
padding: 0 var(--spacing--4xs);
border-radius: 9px;
background: var(--color--background--light-2);
color: var(--color--text--tint-1);
font-size: 11px;
font-weight: var(--font-weight--medium);
}
.chipList {
display: flex;
flex-wrap: wrap;
gap: var(--spacing--3xs);
}
.chip {
display: inline-flex;
align-items: center;
padding: var(--spacing--4xs) var(--spacing--xs);
border-radius: var(--border-radius--base);
font-size: var(--font-size--2xs);
color: var(--color--text);
background: var(--color--background--light-2);
}
</style>

View File

@ -0,0 +1,194 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { N8nButton, N8nIcon, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { McpServerConnectionItem, McpToolInclusionMode, McpToolSettings } from './types';
const props = defineProps<{
item: McpServerConnectionItem;
}>();
const emit = defineEmits<{
save: [settings: McpToolSettings];
disconnect: [];
}>();
const i18n = useI18n();
const initialSettings = (): McpToolSettings =>
props.item.settings ?? {
inclusionMode: 'all',
selectedTools: [],
excludedTools: [],
};
const inclusionMode = ref<McpToolInclusionMode>(initialSettings().inclusionMode);
const selectedTools = ref<string[]>([...initialSettings().selectedTools]);
const excludedTools = ref<string[]>([...initialSettings().excludedTools]);
watch(
() => props.item.id,
() => {
const next = initialSettings();
inclusionMode.value = next.inclusionMode;
selectedTools.value = [...next.selectedTools];
excludedTools.value = [...next.excludedTools];
},
);
const inclusionOptions: Array<{ value: McpToolInclusionMode; label: string }> = [
{ value: 'all', label: i18n.baseText('tools.connection.settings.inclusion.all') },
{ value: 'selected', label: i18n.baseText('tools.connection.settings.inclusion.selected') },
{ value: 'except', label: i18n.baseText('tools.connection.settings.inclusion.except') },
];
const toolOptions = computed(() =>
props.item.availableTools.map((tool) => ({ value: tool.id, label: tool.name })),
);
const showIncludeList = computed(() => inclusionMode.value === 'selected');
const showExcludeList = computed(() => inclusionMode.value === 'except');
function handleSave() {
emit('save', {
inclusionMode: inclusionMode.value,
selectedTools: [...selectedTools.value],
excludedTools: [...excludedTools.value],
});
}
</script>
<template>
<div :class="$style.container">
<div :class="$style.body">
<div :class="$style.field">
<N8nText :class="$style.fieldLabel" tag="label" size="small">
{{ i18n.baseText('tools.connection.settings.toolInclusion') }}
</N8nText>
<N8nSelect
v-model="inclusionMode"
size="medium"
data-test-id="tools-connection-settings-inclusion"
>
<N8nOption
v-for="opt in inclusionOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
/>
</N8nSelect>
</div>
<div v-if="showIncludeList" :class="$style.field">
<N8nText :class="$style.fieldLabel" tag="label" size="small">
{{ i18n.baseText('tools.connection.settings.toolsToInclude') }}
</N8nText>
<N8nSelect
v-model="selectedTools"
multiple
filterable
size="medium"
:class="$style.multiSelect"
:placeholder="i18n.baseText('tools.connection.settings.toolsPlaceholder')"
data-test-id="tools-connection-settings-selected"
>
<N8nOption
v-for="opt in toolOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
/>
</N8nSelect>
</div>
<div v-if="showExcludeList" :class="$style.field">
<N8nText :class="$style.fieldLabel" tag="label" size="small">
{{ i18n.baseText('tools.connection.settings.toolsToExclude') }}
</N8nText>
<N8nSelect
v-model="excludedTools"
multiple
filterable
size="medium"
:class="$style.multiSelect"
:placeholder="i18n.baseText('tools.connection.settings.toolsPlaceholder')"
data-test-id="tools-connection-settings-excluded"
>
<N8nOption
v-for="opt in toolOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
/>
</N8nSelect>
</div>
</div>
<footer :class="$style.footer">
<N8nButton
variant="outline"
size="small"
data-test-id="tools-connection-settings-disconnect"
@click="emit('disconnect')"
>
<N8nIcon icon="trash-2" :size="14" :class="$style.footerIcon" />
<span>{{ i18n.baseText('tools.connection.settings.disconnect') }}</span>
</N8nButton>
<N8nButton
variant="solid"
size="small"
:label="i18n.baseText('tools.connection.settings.save')"
data-test-id="tools-connection-settings-save"
@click="handleSave"
/>
</footer>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
gap: var(--spacing--md);
min-height: 100%;
}
.body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.field {
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
}
.multiSelect {
// Cap the chip-container height so a long selection scrolls instead of
// blowing up the dialog vertically. Matches the tighter Figma rhythm.
:global(.el-select__wrapper) {
min-height: 40px;
max-height: 96px;
overflow-y: auto;
}
}
.fieldLabel {
color: var(--color--text);
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--spacing--md);
border-top: 1px solid var(--color--foreground);
}
.footerIcon {
margin-right: var(--spacing--5xs);
}
</style>

View File

@ -0,0 +1,267 @@
<script setup lang="ts">
import { computed, inject, nextTick, ref, watch } from 'vue';
import { N8nButton, N8nIcon, N8nInput, N8nPopover, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import {
TOOL_CONNECTION_CREDENTIAL_ADAPTER_KEY,
type ToolConnectionItem,
type ToolCredentialRef,
} from './types';
const props = defineProps<{
item: ToolConnectionItem;
credentials: ToolCredentialRef[];
}>();
const emit = defineEmits<{
'select-credential': [item: ToolConnectionItem, authType: string, credentialId: string];
}>();
const i18n = useI18n();
const adapter = inject(TOOL_CONNECTION_CREDENTIAL_ADAPTER_KEY, null);
const isOpen = ref(false);
const searchQuery = ref('');
const searchInputRef = ref<InstanceType<typeof N8nInput> | null>(null);
const selectedCredentialIds = computed(() =>
props.credentials.map((c) => c.credentialId).filter((id): id is string => Boolean(id)),
);
const isConnected = computed(() => selectedCredentialIds.value.length > 0);
const availableCredentials = computed(() => {
if (!adapter) return [];
return props.credentials.flatMap((cred) =>
adapter.getCredentialsByType(cred.authType).map((c) => ({
id: c.id,
name: c.name,
authType: cred.authType,
})),
);
});
const filteredCredentials = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return availableCredentials.value;
return availableCredentials.value.filter((cred) => cred.name.toLowerCase().includes(query));
});
watch(isOpen, (open) => {
if (open) {
searchQuery.value = '';
void nextTick(() => {
(searchInputRef.value?.$el as HTMLElement | undefined)?.querySelector('input')?.focus();
});
}
});
function pickCredential(authType: string, credentialId: string) {
emit('select-credential', props.item, authType, credentialId);
isOpen.value = false;
}
const createAuthType = computed(
() => props.credentials.find((c) => c.required)?.authType ?? props.credentials[0]?.authType,
);
function createCredential() {
if (!createAuthType.value) return;
adapter?.openNewCredential(createAuthType.value);
isOpen.value = false;
}
</script>
<template>
<N8nPopover
v-model:open="isOpen"
side="bottom"
align="end"
:side-offset="6"
:width="'260px'"
:teleported="false"
:z-index="2000"
data-test-id="tool-credential-picker"
>
<template #trigger>
<button
v-if="isConnected"
type="button"
:class="$style.connectedPill"
data-test-id="tool-credential-picker-trigger-connected"
>
<span :class="$style.statusDot" aria-hidden="true" />
<span>{{ i18n.baseText('tools.connection.action.connected') }}</span>
<N8nIcon icon="chevron-down" :size="12" />
</button>
<N8nButton
v-else
variant="solid"
size="small"
data-test-id="tool-credential-picker-trigger-connect"
>
<span>{{ i18n.baseText('tools.connection.action.connect') }}</span>
<N8nIcon icon="chevron-down" :size="14" :class="$style.triggerCaret" />
</N8nButton>
</template>
<template #content>
<div :class="$style.searchWrapper">
<N8nInput
ref="searchInputRef"
v-model="searchQuery"
size="small"
:placeholder="i18n.baseText('tools.connection.credentialPicker.search')"
data-test-id="tool-credential-picker-search"
:class="$style.searchInput"
>
<template #prefix>
<N8nIcon icon="search" :size="14" />
</template>
</N8nInput>
</div>
<ul :class="$style.list" data-test-id="tool-credential-picker-list">
<li v-if="filteredCredentials.length === 0" :class="$style.emptyRow">
<N8nText size="small" color="text-light">
{{ i18n.baseText('tools.connection.credentialPicker.noResults') }}
</N8nText>
</li>
<li
v-for="cred in filteredCredentials"
:key="`${cred.authType}:${cred.id}`"
:class="[$style.row, { [$style.rowActive]: selectedCredentialIds.includes(cred.id) }]"
data-test-id="tool-credential-picker-row"
:data-credential-id="cred.id"
:data-auth-type="cred.authType"
@click="pickCredential(cred.authType, cred.id)"
>
<span :class="$style.rowLabel">{{ cred.name }}</span>
<N8nIcon
v-if="selectedCredentialIds.includes(cred.id)"
icon="check"
:size="14"
:class="$style.rowCheck"
/>
</li>
</ul>
<button
v-if="createAuthType"
type="button"
:class="$style.createRow"
data-test-id="tool-credential-picker-create"
@click="createCredential"
>
<N8nIcon icon="plus" :size="14" />
<span>{{ i18n.baseText('tools.connection.credentialPicker.create') }}</span>
</button>
</template>
</N8nPopover>
</template>
<style lang="scss" module>
.triggerCaret {
margin-left: var(--spacing--4xs);
}
.connectedPill {
display: inline-flex;
align-items: center;
gap: var(--spacing--4xs);
color: var(--color--text--tint-1);
font-size: var(--font-size--2xs);
background: none;
border: 0;
padding: var(--spacing--4xs) var(--spacing--3xs);
cursor: pointer;
border-radius: var(--border-radius--base);
white-space: nowrap;
&:hover {
background: var(--color--background--light-2);
}
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color--success);
flex-shrink: 0;
}
.searchWrapper {
padding: var(--spacing--2xs);
}
.searchInput {
width: 100%;
}
.list {
list-style: none;
padding: 0 var(--spacing--4xs) var(--spacing--4xs);
margin: 0;
max-height: 260px;
overflow-y: auto;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs) var(--spacing--2xs);
cursor: pointer;
border-radius: var(--border-radius--base);
font-size: var(--font-size--xs);
line-height: var(--line-height--md);
transition: background-color 80ms ease;
&:hover {
background: var(--color--background--light-2);
}
}
.rowActive {
color: var(--color--text);
font-weight: var(--font-weight--medium);
}
.rowLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowCheck {
color: var(--color--text--tint-1);
flex-shrink: 0;
}
.emptyRow {
padding: var(--spacing--2xs);
text-align: center;
}
.createRow {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
width: 100%;
padding: var(--spacing--2xs);
border-top: 1px solid var(--color--foreground);
background: none;
border-left: 0;
border-right: 0;
border-bottom: 0;
color: var(--color--text);
font-size: var(--font-size--2xs);
cursor: pointer;
text-align: left;
&:hover {
background: var(--color--background--light-2);
}
}
</style>

View File

@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed } from 'vue';
import { N8nIcon, N8nIconButton, N8nNodeIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import DefaultDetailBody from './DefaultDetailBody.vue';
import McpDetailBody from './McpDetailBody.vue';
import ToolCredentialPicker from './ToolCredentialPicker.vue';
import { resolveToolItemIcon } from './toolItemIcon';
import type { ToolConnectionItem } from './types';
const props = defineProps<{
item: ToolConnectionItem;
}>();
const emit = defineEmits<{
back: [];
close: [];
'select-credential': [item: ToolConnectionItem, authType: string, credentialId: string];
}>();
const i18n = useI18n();
const placeholderIcon = computed(() => {
switch (props.item.kind) {
case 'mcp-server':
return 'plug';
case 'workflow':
return 'workflow';
case 'agent':
return 'bot';
case 'data-store':
return 'database';
case 'node':
default:
return 'toolbox';
}
});
const resolvedIcon = computed(() => resolveToolItemIcon(props.item));
</script>
<template>
<div :class="$style.container" data-test-id="tools-connection-detail">
<header :class="$style.header">
<div :class="$style.headerLeft">
<N8nIconButton
icon="arrow-left"
variant="ghost"
size="small"
:aria-label="i18n.baseText('tools.connection.detail.back')"
data-test-id="tools-connection-detail-back"
@click="emit('back')"
/>
<div :class="$style.iconWrapper" aria-hidden="true">
<N8nNodeIcon
v-if="resolvedIcon"
:type="resolvedIcon.type"
:src="resolvedIcon.type === 'file' ? resolvedIcon.src : undefined"
:name="resolvedIcon.type === 'icon' ? resolvedIcon.name : undefined"
:color="resolvedIcon.type === 'icon' ? resolvedIcon.color : undefined"
:size="20"
/>
<N8nIcon v-else :icon="placeholderIcon" :size="20" :class="$style.iconFallback" />
</div>
<N8nText :class="$style.title" tag="h2" bold>{{ item.title }}</N8nText>
</div>
<div :class="$style.headerActions">
<ToolCredentialPicker
v-if="item.credentials?.length"
:item="item"
:credentials="item.credentials"
@select-credential="
(toolItem, authType, credentialId) =>
emit('select-credential', toolItem, authType, credentialId)
"
/>
<N8nIconButton
icon="x"
variant="ghost"
size="small"
:aria-label="i18n.baseText('tools.connection.action.close')"
data-test-id="tools-connection-detail-close"
@click="emit('close')"
/>
</div>
</header>
<slot name="body" :item="item">
<McpDetailBody v-if="item.kind === 'mcp-server'" :item="item" />
<DefaultDetailBody v-else :item="item" />
</slot>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
gap: var(--spacing--md);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--sm);
}
.headerLeft {
display: flex;
align-items: center;
gap: var(--spacing--xs);
min-width: 0;
flex: 1 1 auto;
}
.headerActions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.iconWrapper {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.iconFallback {
color: var(--color--text--tint-1);
}
.title {
margin: 0;
font-size: var(--font-size--md);
font-weight: var(--font-weight--medium);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -0,0 +1,238 @@
<script setup lang="ts">
import { computed } from 'vue';
import { N8nButton, N8nIcon, N8nIconButton, N8nNodeIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { ToolConnectionItem } from './types';
import { resolveToolItemIcon } from './toolItemIcon';
const props = defineProps<{
item: ToolConnectionItem;
}>();
const emit = defineEmits<{
'open-detail': [item: ToolConnectionItem];
connect: [item: ToolConnectionItem];
}>();
const i18n = useI18n();
const placeholderIcon = computed(() => {
switch (props.item.kind) {
case 'mcp-server':
return 'plug';
case 'workflow':
return 'workflow';
case 'agent':
return 'bot';
case 'data-store':
return 'database';
case 'node':
default:
return 'toolbox';
}
});
const resolvedIcon = computed(() => resolveToolItemIcon(props.item));
function handleRowClick() {
emit('open-detail', props.item);
}
function handleConnect() {
emit('connect', props.item);
}
function handleOpenDetail() {
emit('open-detail', props.item);
}
</script>
<template>
<div
:class="[$style.row, $style[`row--${item.kind}`]]"
:data-test-id="`tools-connection-row`"
:data-row-kind="item.kind"
>
<button
type="button"
:class="$style.mainAction"
data-test-id="tools-connection-row-main"
@click="handleRowClick"
>
<template v-if="item.kind === 'workflow'">
<span :class="$style.workflowIcon" aria-hidden="true">
<N8nIcon icon="workflow" :size="20" />
</span>
<N8nText :class="$style.workflowTitle" tag="span" bold>{{ item.title }}</N8nText>
</template>
<template v-else>
<span :class="$style.iconWrapper" aria-hidden="true">
<N8nNodeIcon
v-if="resolvedIcon"
:type="resolvedIcon.type"
:src="resolvedIcon.type === 'file' ? resolvedIcon.src : undefined"
:name="resolvedIcon.type === 'icon' ? resolvedIcon.name : undefined"
:color="resolvedIcon.type === 'icon' ? resolvedIcon.color : undefined"
:size="20"
/>
<N8nIcon v-else :icon="placeholderIcon" :size="20" :class="$style.iconFallback" />
</span>
<span :class="$style.text">
<N8nText :class="$style.title" tag="span" bold>{{ item.title }}</N8nText>
<N8nText
v-if="item.description"
:class="$style.description"
tag="span"
size="small"
color="text-light"
>
{{ item.description }}
</N8nText>
</span>
</template>
</button>
<div :class="$style.action">
<template v-if="item.isConnected">
<div :class="$style.connectedPill" data-test-id="tools-connection-row-connected">
<span :class="$style.statusDot" aria-hidden="true" />
<span>{{ i18n.baseText('tools.connection.action.connected') }}</span>
</div>
<N8nIconButton
icon="settings"
variant="ghost"
size="small"
:aria-label="i18n.baseText('tools.connection.action.configure')"
data-test-id="tools-connection-row-configure"
@click="handleOpenDetail"
/>
</template>
<template v-else>
<N8nButton
:label="i18n.baseText('tools.connection.action.connect')"
variant="outline"
size="small"
data-test-id="tools-connection-row-connect"
@click="handleConnect"
/>
</template>
</div>
</div>
</template>
<style lang="scss" module>
.row {
display: flex;
align-items: center;
gap: var(--spacing--xs);
width: 100%;
padding: var(--spacing--xs) var(--spacing--3xs);
min-height: 58px;
border-radius: var(--border-radius--base);
transition: background-color 120ms ease;
&:hover {
background: var(--color--background--light-2);
}
}
.mainAction {
display: flex;
align-items: center;
gap: var(--spacing--xs);
flex: 1 1 0;
min-width: 0;
align-self: stretch;
padding: 0;
border: 0;
background: none;
color: inherit;
text-align: left;
cursor: pointer;
&:focus-visible {
outline: var(--focus--border-width) solid var(--focus--border-color);
outline-offset: 2px;
}
}
.row--workflow {
min-height: 48px;
}
.iconWrapper {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color--background--light-2);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.iconFallback {
color: var(--color--text--tint-1);
}
.workflowIcon {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color--primary);
}
.text {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.workflowTitle {
flex: 1 1 0;
min-width: 0;
font-weight: var(--font-weight--medium);
}
.title {
font-weight: var(--font-weight--medium);
}
.description {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.action {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--spacing--3xs);
}
.connectedPill {
display: inline-flex;
align-items: center;
gap: var(--spacing--4xs);
color: var(--color--text--tint-1);
font-size: var(--font-size--2xs);
padding: 0 var(--spacing--3xs);
white-space: nowrap;
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color--success);
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,222 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { N8nIcon, N8nIconButton, N8nNodeIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import DefaultDetailBody from './DefaultDetailBody.vue';
import McpDetailBody from './McpDetailBody.vue';
import ToolCredentialPicker from './ToolCredentialPicker.vue';
import { resolveToolItemIcon } from './toolItemIcon';
import type { ToolConnectionItem, ToolConnectionSettings } from './types';
const props = defineProps<{
item: ToolConnectionItem;
}>();
const emit = defineEmits<{
back: [];
close: [];
disconnect: [item: ToolConnectionItem];
save: [item: ToolConnectionItem, settings?: ToolConnectionSettings];
'select-credential': [item: ToolConnectionItem, authType: string, credentialId: string];
}>();
const i18n = useI18n();
const resolvedIcon = computed(() => resolveToolItemIcon(props.item));
type InternalTab = 'settings' | 'details';
const activeTab = ref<InternalTab>('settings');
function onSave(settings?: ToolConnectionSettings) {
emit('save', props.item, settings);
}
function onDisconnect() {
emit('disconnect', props.item);
}
function onClose() {
emit('close');
}
</script>
<template>
<div :class="$style.container" data-test-id="tools-connection-settings">
<header :class="$style.header">
<div :class="$style.headerLeft">
<N8nIconButton
icon="arrow-left"
variant="ghost"
size="small"
:aria-label="i18n.baseText('tools.connection.detail.back')"
data-test-id="tools-connection-settings-back"
@click="emit('back')"
/>
<div :class="$style.iconWrapper" aria-hidden="true">
<N8nNodeIcon
v-if="resolvedIcon"
:type="resolvedIcon.type"
:src="resolvedIcon.type === 'file' ? resolvedIcon.src : undefined"
:name="resolvedIcon.type === 'icon' ? resolvedIcon.name : undefined"
:color="resolvedIcon.type === 'icon' ? resolvedIcon.color : undefined"
:size="20"
/>
<N8nIcon v-else icon="plug" :size="20" :class="$style.iconFallback" />
</div>
<N8nText :class="$style.title" tag="h2" bold>{{ item.title }}</N8nText>
</div>
<div :class="$style.headerActions">
<ToolCredentialPicker
v-if="item.credentials?.length"
:item="item"
:credentials="item.credentials"
@select-credential="
(toolItem, authType, credentialId) =>
emit('select-credential', toolItem, authType, credentialId)
"
/>
<N8nIconButton
icon="x"
variant="ghost"
size="small"
:aria-label="i18n.baseText('tools.connection.action.close')"
data-test-id="tools-connection-settings-close"
@click="onClose"
/>
</div>
</header>
<div :class="$style.tabs" role="tablist">
<button
type="button"
role="tab"
:class="[$style.tab, { [$style.tabActive]: activeTab === 'settings' }]"
:aria-selected="activeTab === 'settings'"
data-test-id="tools-connection-settings-tab-settings"
@click="activeTab = 'settings'"
>
{{ i18n.baseText('tools.connection.tabs.settings') }}
</button>
<button
type="button"
role="tab"
:class="[$style.tab, { [$style.tabActive]: activeTab === 'details' }]"
:aria-selected="activeTab === 'details'"
data-test-id="tools-connection-settings-tab-details"
@click="activeTab = 'details'"
>
{{ i18n.baseText('tools.connection.tabs.details') }}
</button>
</div>
<div :class="$style.bodyWrapper">
<slot
v-if="activeTab === 'settings'"
name="body"
:item="item"
:on-save="onSave"
:on-disconnect="onDisconnect"
:on-close="onClose"
/>
<template v-else>
<McpDetailBody v-if="item.kind === 'mcp-server'" :item="item" />
<DefaultDetailBody v-else :item="item" />
</template>
</div>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
gap: var(--spacing--md);
min-height: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--sm);
}
.headerLeft {
display: flex;
align-items: center;
gap: var(--spacing--xs);
min-width: 0;
flex: 1 1 auto;
}
.headerActions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.iconWrapper {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.iconFallback {
color: var(--color--text--tint-1);
}
.title {
margin: 0;
font-size: var(--font-size--md);
font-weight: var(--font-weight--medium);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tabs {
display: flex;
gap: var(--spacing--md);
border-bottom: 1px solid var(--color--foreground);
flex-shrink: 0;
}
.tab {
background: none;
border: 0;
padding: var(--spacing--3xs) 0;
margin-bottom: -1px;
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--medium);
color: var(--color--text--tint-1);
cursor: pointer;
border-bottom: 2px solid transparent;
transition:
color 120ms ease,
border-color 120ms ease;
&:hover {
color: var(--color--text);
}
&:focus-visible {
outline: var(--focus--border-width) solid var(--focus--border-color);
outline-offset: 2px;
}
}
.tabActive {
color: var(--color--primary);
border-bottom-color: var(--color--primary);
}
.bodyWrapper {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
</style>

View File

@ -0,0 +1,360 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { provide, ref } from 'vue';
import { N8nButton } from '@n8n/design-system';
import ToolsConnectionModal from './ToolsConnectionModal.vue';
import McpToolSettingsContent from './McpToolSettingsContent.vue';
import {
connectedMcpFixture,
makeLargeMcpList,
realisticItems,
sampleCredentials,
} from './fixtures';
import {
TOOL_CONNECTION_CREDENTIAL_ADAPTER_KEY,
type ToolConnectionCredentialAdapter,
type NodeConnectionItem,
type SectionKey,
type ToolConnectionItem,
type WorkflowConnectionItem,
} from './types';
/**
* Stand-in for the editor-ui `NodeToolSettingsContent` / `WorkflowToolConfigContent`
* those pull in workflow stores that can't load in Storybook. The stub renders
* a simple labelled placeholder so the inline-settings flow can be exercised
* end-to-end without dragging in app dependencies.
*/
const PlaceholderSettingsBody = {
props: ['title'],
template: `
<div style="padding: var(--spacing--md); border: 1px dashed var(--color--foreground); border-radius: var(--border-radius--base); display: flex; flex-direction: column; gap: var(--spacing--2xs);">
<strong>{{ title }}</strong>
<span style="color: var(--color--text--tint-1); font-size: var(--font-size--xs);">
Consumer-supplied settings body would render here (NodeToolSettingsContent,
WorkflowToolConfigContent, custom form, etc.).
</span>
</div>
`,
};
const INSTANCE_AI_SECTIONS: SectionKey[] = ['connected', 'nodes'];
const AGENT_BUILDER_SECTIONS: SectionKey[] = ['connected', 'nodes', 'agents', 'data', 'workflows'];
const meta = {
title: 'Modules/ToolsConnectionModal',
component: ToolsConnectionModal,
parameters: {
docs: {
description: {
component:
'Shared modal for connecting MCP servers, nodes, and workflows. Same component serves Instance AI and Agent Builder — driven by the `sections` prop. When the user types in the search input, a Services / Workflows tab strip appears as scroll-to-section navigation.',
},
},
},
argTypes: {
sections: {
control: { type: 'check' },
options: ['connected', 'nodes', 'agents', 'data', 'workflows'],
},
},
} satisfies Meta<typeof ToolsConnectionModal>;
export default meta;
type Story = StoryObj<typeof meta>;
/**
* All stories share the same shell: a primary "Open Tools modal" button toggles
* `isOpen` so the modal can be opened/closed at will while the props
* documentation stays visible. `v-bind="args"` comes BEFORE the v-model
* bindings so the local refs win over any default args.
*/
function renderWithTrigger(
initialDetail: ToolConnectionItem | null = null,
initialDetailMode: 'detail' | 'settings' | undefined = undefined,
) {
return (args: Record<string, unknown>) => ({
components: {
ToolsConnectionModal,
McpToolSettingsContent,
PlaceholderSettingsBody,
N8nButton,
},
setup() {
const isOpen = ref(false);
const detailItem = ref<ToolConnectionItem | null>(initialDetail);
const detailMode = ref(initialDetailMode);
// Provide a fake credential adapter so the credential-picker dropdown
// has realistic entries (Jake's Notion, etc.) in Storybook without
// importing editor-ui stores (which would pull in the n8n-workflow ->
// @n8n/tournament chain that breaks Storybook's dev server).
const fakeAdapter: ToolConnectionCredentialAdapter = {
getCredentialsByType: (authType) =>
sampleCredentials.filter((cred) => cred.type === authType),
openNewCredential: (authType) => {
console.log('[story] would open credential editor for', authType);
},
};
provide(TOOL_CONNECTION_CREDENTIAL_ADAPTER_KEY, fakeAdapter);
function onOpenDetail(item: ToolConnectionItem) {
console.log('[story] open-detail', item);
detailMode.value = item.isConnected ? 'settings' : 'detail';
}
function onConnect(item: ToolConnectionItem) {
console.log('[story] connect', item);
}
return { args, isOpen, detailItem, detailMode, onOpenDetail, onConnect };
},
template: `
<div style="padding: var(--spacing--md); display: flex; flex-direction: column; gap: var(--spacing--sm); align-items: flex-start;">
<N8nButton variant="solid" label="Open Tools modal" @click="isOpen = true" />
<ToolsConnectionModal
v-bind="args"
v-model:open="isOpen"
v-model:detailItem="detailItem"
:detail-mode="detailMode"
@open-detail="onOpenDetail"
@connect="onConnect"
@disconnect="(item) => console.log('[story] disconnect', item)"
@save="(item, settings) => console.log('[story] save', item, settings)"
@select-credential="(item, authType, credentialId) => console.log('[story] select-credential', item, authType, credentialId)"
>
<template #settings-body="{ item, onSave, onDisconnect }">
<McpToolSettingsContent
v-if="item.kind === 'mcp-server'"
:item="item"
@save="onSave"
@disconnect="onDisconnect"
/>
<PlaceholderSettingsBody
v-else-if="item.kind === 'node'"
title="Node tool settings"
/>
<PlaceholderSettingsBody
v-else-if="item.kind === 'workflow'"
title="Workflow tool settings"
/>
<PlaceholderSettingsBody
v-else
:title="item.kind + ' settings'"
/>
</template>
</ToolsConnectionModal>
</div>
`,
});
}
/**
* Default story full Agent Builder shape (connected MCPs, available MCPs,
* available nodes, available workflows) with realistic fixture data so every
* piece of UX can be exercised: rows, connected pill, configure gear, search,
* tabs, detail navigation, workflow rows.
*/
export const Default: Story = {
render: renderWithTrigger(),
args: {
items: realisticItems,
sections: AGENT_BUILDER_SECTIONS,
},
};
export const InstanceAi: Story = {
render: renderWithTrigger(),
args: {
items: realisticItems,
sections: INSTANCE_AI_SECTIONS,
},
};
/**
* Demonstrates all five section keys side by side connected items at the top,
* then services, agents, data stores, and workflows.
*/
export const AllSections: Story = {
render: renderWithTrigger(),
args: {
items: realisticItems,
sections: AGENT_BUILDER_SECTIONS,
},
};
export const Empty: Story = {
render: renderWithTrigger(),
args: {
items: [],
sections: AGENT_BUILDER_SECTIONS,
},
};
/**
* Detail view shown when an item is NOT connected preview-style with the big
* orange Connect button.
*/
export const McpDetail: Story = {
render: renderWithTrigger({
...connectedMcpFixture,
isConnected: false,
settings: undefined,
}),
args: {
items: realisticItems,
sections: INSTANCE_AI_SECTIONS,
},
};
/**
* Settings view shown when a connected MCP item is selected Settings/Details
* internal tabs, tool inclusion + multi-select, Disconnect / Save settings.
*/
export const McpSettings: Story = {
render: renderWithTrigger(connectedMcpFixture, 'settings'),
args: {
items: realisticItems,
sections: INSTANCE_AI_SECTIONS,
},
};
export const LargeList: Story = {
render: renderWithTrigger(),
args: {
items: makeLargeMcpList(300),
sections: ['nodes'],
},
};
/**
* Demonstrates the inline settings flow for a non-MCP kind. The shared modal's
* `#settings-body` slot is wired to a placeholder body component consumers
* (Agent Builder etc.) drop their own form here. The header still renders a
* credential picker because the node item carries a `credentials` entry.
*/
export const NodeToolInlineSettings: Story = {
render: renderWithTrigger(
{
id: 'node-openai',
kind: 'node',
title: 'OpenAI',
isConnected: true,
nodeTypeName: '@n8n/n8n-nodes-langchain.openAi',
iconSource: {
type: 'file',
src: 'https://upload.wikimedia.org/wikipedia/commons/0/04/ChatGPT_logo.svg',
},
credentials: [
{
authType: 'openAiApi',
credentialId: 'cred-openai-1',
required: true,
},
],
} satisfies NodeConnectionItem,
'settings',
),
args: {
items: realisticItems,
sections: AGENT_BUILDER_SECTIONS,
},
};
/**
* Multi-credential header an HTTP-style tool that accepts both OAuth2 and a
* bearer token. The header stacks two credential pickers, each filtered by its
* own auth type. Real two-credential nodes are rare but this confirms the
* iteration + per-credential labelling works.
*/
export const MultiCredentialHeader: Story = {
render: renderWithTrigger(
{
id: 'node-http-multi',
kind: 'node',
title: 'HTTP Request',
description: 'Make HTTP requests with OAuth2 or a bearer token.',
isConnected: true,
nodeTypeName: 'n8n-nodes-base.httpRequestTool',
credentials: [
{ authType: 'oAuth2Api', required: false },
{ authType: 'httpBearerAuth', required: false },
],
} satisfies NodeConnectionItem,
'settings',
),
args: {
items: realisticItems,
sections: AGENT_BUILDER_SECTIONS,
},
};
/**
* Workflow / agent / data-store kinds carry no `credentials` the header
* renders no credential picker (just the back arrow, icon, title, and close X).
*/
export const NoCredentialsHeader: Story = {
render: renderWithTrigger(
{
id: 'wf-summariser',
kind: 'workflow',
title: 'Summariser',
description: 'Summarises long-form content into bullet points.',
isConnected: true,
workflowId: 'wf-summariser-1',
} satisfies WorkflowConnectionItem,
'settings',
),
args: {
items: realisticItems,
sections: AGENT_BUILDER_SECTIONS,
},
};
/**
* Rich node detail view long description, version, docs link, operations
* chips grouped by resource, required-credentials chips. Pulled from the
* `availableOpenAi` fixture which carries the full detail-field set.
*/
export const NodeDetail: Story = {
render: renderWithTrigger(
realisticItems.find((i) => i.id === 'node-openai') as ToolConnectionItem,
'detail',
),
args: {
items: realisticItems,
sections: AGENT_BUILDER_SECTIONS,
},
};
/**
* Workflow detail uses the shared default body just the long description
* (no metadata cells, no chips). Demonstrates the minimal default for kinds
* that haven't earned a dedicated body component yet.
*/
export const WorkflowDetail: Story = {
render: renderWithTrigger(
realisticItems.find((i) => i.id === 'workflow-summariser') as ToolConnectionItem,
'detail',
),
args: {
items: realisticItems,
sections: AGENT_BUILDER_SECTIONS,
},
};
/**
* Default body's empty state exercised by a sparse fixture (no
* `longDescription`). Confirms the "No additional details" placeholder
* renders inside the modal body.
*/
export const EmptyDetail: Story = {
render: renderWithTrigger(
realisticItems.find((i) => i.id === 'workflow-email-parser') as ToolConnectionItem,
'detail',
),
args: {
items: realisticItems,
sections: AGENT_BUILDER_SECTIONS,
},
};

View File

@ -0,0 +1,399 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import { N8nDialog, N8nIcon, N8nInput, N8nRecycleScroller, N8nText } from '@n8n/design-system';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
import { useDebounceFn } from '@vueuse/core';
import { DEBOUNCE_TIME, getDebounceTime } from '@/app/constants/durations';
import ToolRow from './ToolRow.vue';
import ToolDetailView from './ToolDetailView.vue';
import ToolSettingsView from './ToolSettingsView.vue';
import {
SECTION_TAB,
TAB_ORDER,
type FlattenedRow,
type SectionKey,
type TabId,
type ToolConnectionItem,
type ToolConnectionSettings,
} from './types';
const props = withDefaults(
defineProps<{
open?: boolean;
items: ToolConnectionItem[];
sections: SectionKey[];
detailItem?: ToolConnectionItem | null;
detailMode?: 'detail' | 'settings';
}>(),
{
open: false,
detailItem: null,
detailMode: 'detail',
},
);
const emit = defineEmits<{
'update:open': [value: boolean];
'update:detailItem': [value: ToolConnectionItem | null];
disconnect: [item: ToolConnectionItem];
save: [item: ToolConnectionItem, settings?: ToolConnectionSettings];
'select-credential': [item: ToolConnectionItem, authType: string, credentialId: string];
'open-detail': [item: ToolConnectionItem];
connect: [item: ToolConnectionItem];
}>();
const i18n = useI18n();
const ITEM_HEIGHT = 58;
const searchQuery = ref('');
const debouncedSearchQuery = ref('');
const setDebouncedSearch = useDebounceFn((value: string) => {
debouncedSearchQuery.value = value;
}, getDebounceTime(DEBOUNCE_TIME.INPUT.SEARCH));
watch(searchQuery, (value) => {
void setDebouncedSearch(value);
});
const activeTab = ref<TabId>('services');
const searchInputRef = useTemplateRef('searchInputRef');
const scrollerRef = useTemplateRef('scrollerRef');
function focusSearchInput() {
void nextTick(() => {
searchInputRef.value?.focus();
});
}
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
searchQuery.value = '';
debouncedSearchQuery.value = '';
activeTab.value = 'services';
focusSearchInput();
}
},
);
onMounted(() => {
if (props.open) {
focusSearchInput();
}
});
const hasActiveSearch = computed(() => debouncedSearchQuery.value.length > 0);
function matchesQuery(item: ToolConnectionItem): boolean {
if (!debouncedSearchQuery.value) return true;
const query = debouncedSearchQuery.value.toLowerCase();
return (
item.title.toLowerCase().includes(query) ||
(item.description ?? '').toLowerCase().includes(query)
);
}
const hasConnectedSection = computed(() => props.sections.includes('connected'));
const SECTION_KINDS: Record<Exclude<SectionKey, 'connected'>, Array<ToolConnectionItem['kind']>> = {
nodes: ['node', 'mcp-server'],
agents: ['agent'],
data: ['data-store'],
workflows: ['workflow'],
};
function itemsForSection(section: SectionKey): ToolConnectionItem[] {
if (section === 'connected') return props.items.filter((item) => item.isConnected);
const kinds = SECTION_KINDS[section];
return props.items.filter(
(item) => kinds.includes(item.kind) && (hasConnectedSection.value ? !item.isConnected : true),
);
}
const SECTION_I18N_KEY: Record<Exclude<SectionKey, 'connected'>, BaseTextKey> = {
nodes: 'tools.connection.sections.availableNodes',
agents: 'tools.connection.sections.availableAgents',
data: 'tools.connection.sections.availableData',
workflows: 'tools.connection.sections.availableWorkflows',
};
function sectionTitle(section: SectionKey): string | null {
if (section === 'connected') return null;
return i18n.baseText(SECTION_I18N_KEY[section]);
}
const flattenedRows = computed<FlattenedRow[]>(() => {
const rows: FlattenedRow[] = [];
for (const section of props.sections) {
const sectionItems = itemsForSection(section).filter(matchesQuery);
if (sectionItems.length === 0) continue;
const title = sectionTitle(section);
if (title !== null) {
rows.push({
kind: 'section-header',
key: `header:${section}`,
section,
title,
count: sectionItems.length,
});
}
for (const item of sectionItems) {
rows.push({ kind: 'item', key: `item:${section}:${item.id}`, section, item });
}
}
return rows;
});
const availableTabs = computed<TabId[]>(() => {
const tabsWithRows = new Set<TabId>();
for (const section of props.sections) {
if (itemsForSection(section).filter(matchesQuery).length > 0) {
tabsWithRows.add(SECTION_TAB[section]);
}
}
return TAB_ORDER.filter((id) => tabsWithRows.has(id));
});
const tabsVisible = computed(() => availableTabs.value.length > 1);
function findFirstRowKeyForTab(tab: TabId): string | undefined {
return flattenedRows.value.find((row) => SECTION_TAB[row.section] === tab)?.key;
}
async function selectTab(tab: TabId) {
activeTab.value = tab;
const targetKey = findFirstRowKeyForTab(tab);
if (!targetKey) return;
await nextTick();
scrollerRef.value?.scrollToKey(targetKey);
}
const TAB_I18N: Record<TabId, BaseTextKey> = {
services: 'tools.connection.tabs.services',
agents: 'tools.connection.tabs.agents',
data: 'tools.connection.tabs.data',
workflows: 'tools.connection.tabs.workflows',
};
watch(availableTabs, (matching) => {
if (matching.length > 0 && !matching.includes(activeTab.value)) {
activeTab.value = matching[0];
}
});
const isListEmpty = computed(() => flattenedRows.value.length === 0);
const emptyMessage = computed(() => {
if (hasActiveSearch.value) {
return i18n.baseText('tools.connection.empty.noResults', {
interpolate: { query: debouncedSearchQuery.value },
});
}
return i18n.baseText('tools.connection.empty.title');
});
function openDetail(item: ToolConnectionItem) {
emit('open-detail', item);
emit('update:detailItem', item);
}
function closeDetail() {
emit('update:detailItem', null);
}
function handleOpenChange(value: boolean) {
emit('update:open', value);
if (!value) {
closeDetail();
}
}
</script>
<template>
<N8nDialog
:open="open"
size="xlarge"
:header="detailItem ? '' : i18n.baseText('tools.connection.title')"
:show-close-button="!detailItem"
:aria-label="i18n.baseText('tools.connection.title')"
data-test-id="tools-connection-modal"
@update:open="handleOpenChange"
>
<div :class="$style.body">
<ToolSettingsView
v-if="detailItem && detailMode === 'settings'"
:key="detailItem.id"
:item="detailItem"
@back="closeDetail"
@close="handleOpenChange(false)"
@disconnect="emit('disconnect', $event)"
@save="(item, settings) => emit('save', item, settings)"
@select-credential="
(item, authType, credentialId) => emit('select-credential', item, authType, credentialId)
"
>
<template v-if="$slots['settings-body']" #body="slotProps">
<slot name="settings-body" v-bind="slotProps" />
</template>
</ToolSettingsView>
<ToolDetailView
v-else-if="detailItem"
:item="detailItem"
@back="closeDetail"
@close="handleOpenChange(false)"
@select-credential="
(item, authType, credentialId) => emit('select-credential', item, authType, credentialId)
"
>
<template v-if="$slots['detail-body']" #body="slotProps">
<slot name="detail-body" v-bind="slotProps" />
</template>
</ToolDetailView>
<template v-else>
<N8nInput
ref="searchInputRef"
v-model="searchQuery"
:placeholder="i18n.baseText('tools.connection.search.placeholder')"
clearable
data-test-id="tools-connection-search"
:class="$style.searchInput"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nInput>
<div
v-if="tabsVisible"
:class="$style.tabs"
role="tablist"
data-test-id="tools-connection-tabs"
>
<button
v-for="tab in availableTabs"
:key="tab"
type="button"
role="tab"
:class="[$style.tab, { [$style.tabActive]: activeTab === tab }]"
:aria-selected="activeTab === tab"
:data-test-id="`tools-connection-tab-${tab}`"
@click="selectTab(tab)"
>
{{ i18n.baseText(TAB_I18N[tab]) }}
</button>
</div>
<div v-if="isListEmpty" :class="$style.empty" data-test-id="tools-connection-empty">
<N8nText color="text-light">{{ emptyMessage }}</N8nText>
</div>
<div v-else :class="$style.listWrapper">
<N8nRecycleScroller
ref="scrollerRef"
:items="flattenedRows"
:item-size="ITEM_HEIGHT"
item-key="key"
:class="$style.scroller"
>
<template #default="{ item: row }">
<div
v-if="row.kind === 'section-header'"
:class="$style.sectionHeader"
data-test-id="tools-connection-section-header"
>
<N8nText size="small" color="text-light">
{{ row.title }}
</N8nText>
</div>
<ToolRow
v-else
:item="row.item"
@open-detail="openDetail($event)"
@connect="emit('connect', $event)"
/>
</template>
</N8nRecycleScroller>
</div>
</template>
</div>
</N8nDialog>
</template>
<style lang="scss" module>
.body {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
height: 70vh;
max-height: 640px;
min-height: 0;
margin-top: var(--spacing--2xs);
}
.searchInput {
width: 100%;
flex-shrink: 0;
}
.tabs {
display: flex;
gap: var(--spacing--md);
border-bottom: 1px solid var(--color--background--light-2);
flex-shrink: 0;
}
.tab {
background: none;
border: 0;
padding: var(--spacing--3xs) 0;
margin-bottom: -1px;
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--medium);
color: var(--color--text--tint-1);
cursor: pointer;
border-bottom: 2px solid transparent;
transition:
color 120ms ease,
border-color 120ms ease;
&:hover {
color: var(--color--text);
}
&:focus-visible {
outline: var(--focus--border-width) solid var(--focus--border-color);
outline-offset: 2px;
}
}
.tabActive {
color: var(--color--primary);
border-bottom-color: var(--color--primary);
}
.listWrapper {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.scroller {
height: 100%;
overflow-y: auto;
}
.sectionHeader {
display: flex;
align-items: center;
padding: var(--spacing--xs) var(--spacing--3xs) var(--spacing--3xs);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing--xl);
min-height: 200px;
}
</style>

View File

@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import DefaultDetailBody from '../DefaultDetailBody.vue';
import type {
AgentConnectionItem,
DataStoreConnectionItem,
ToolConnectionItem,
WorkflowConnectionItem,
} from '../types';
const renderBody = createComponentRenderer(DefaultDetailBody);
function render(item: ToolConnectionItem) {
return renderBody({ props: { item }, pinia: createTestingPinia() });
}
const workflowItem: WorkflowConnectionItem = {
id: 'wf-1',
kind: 'workflow',
title: 'Summariser',
isConnected: false,
workflowId: 'wf-1',
};
const agentItem: AgentConnectionItem = {
id: 'ag-1',
kind: 'agent',
title: 'Code Reviewer',
isConnected: false,
agentId: 'ag-1',
};
const dataStoreItem: DataStoreConnectionItem = {
id: 'ds-1',
kind: 'data-store',
title: 'Customers',
isConnected: false,
dataStoreId: 'ds-1',
};
describe('DefaultDetailBody', () => {
it('renders longDescription when present', () => {
const { queryByText } = render({
...workflowItem,
longDescription: 'Summarises long-form content into bullet points.',
});
expect(queryByText('Summarises long-form content into bullet points.')).toBeTruthy();
});
it('renders the placeholder when longDescription is absent', () => {
const { getByTestId } = render(workflowItem);
expect(getByTestId('tools-connection-detail-placeholder')).toBeTruthy();
});
it.each([
['workflow', workflowItem],
['agent', agentItem],
['data-store', dataStoreItem],
])('works for %s items', (_kind, item) => {
const { queryByText } = render({ ...item, longDescription: 'Hello world' });
expect(queryByText('Hello world')).toBeTruthy();
});
});

View File

@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { fireEvent } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import ToolCredentialPicker from '../ToolCredentialPicker.vue';
import {
TOOL_CONNECTION_CREDENTIAL_ADAPTER_KEY,
type McpServerConnectionItem,
type NodeConnectionItem,
type PickableCredential,
type ToolConnectionCredentialAdapter,
type ToolCredentialRef,
} from '../types';
const renderPicker = createComponentRenderer(ToolCredentialPicker);
function makeAdapter(credentials: PickableCredential[]): ToolConnectionCredentialAdapter {
return {
getCredentialsByType: (authType) => credentials.filter((c) => c.type === authType),
openNewCredential: () => {},
};
}
const baseMcpItem: McpServerConnectionItem = {
id: 'mcp-1',
kind: 'mcp-server',
title: 'Notion',
description: 'Notion MCP',
availableTools: [],
isConnected: false,
};
const baseNodeItem: NodeConnectionItem = {
id: 'node:slack',
kind: 'node',
title: 'Slack',
isConnected: false,
nodeTypeName: 'n8n-nodes-base.slack',
};
function render(
item: McpServerConnectionItem | NodeConnectionItem,
credentials: ToolCredentialRef[],
storeCredentials: PickableCredential[] = [],
) {
return renderPicker({
props: { item, credentials },
pinia: createTestingPinia(),
global: {
provide: {
[TOOL_CONNECTION_CREDENTIAL_ADAPTER_KEY as symbol]: makeAdapter(storeCredentials),
},
},
});
}
describe('ToolCredentialPicker', () => {
it('shows the Connect button when no credential is selected', () => {
const { getByTestId, queryByTestId } = render(baseMcpItem, [{ authType: 'mcpOAuth2Api' }]);
expect(getByTestId('tool-credential-picker-trigger-connect')).toBeTruthy();
expect(queryByTestId('tool-credential-picker-trigger-connected')).toBeNull();
});
it('shows the Connected pill when at least one credential is selected', () => {
const { getByTestId, queryByTestId } = render(baseMcpItem, [
{ authType: 'mcpOAuth2Api', credentialId: 'cred-1' },
]);
expect(getByTestId('tool-credential-picker-trigger-connected')).toBeTruthy();
expect(queryByTestId('tool-credential-picker-trigger-connect')).toBeNull();
});
it('shows the generic Connect label on the trigger', () => {
const { getByTestId } = render(baseNodeItem, [{ authType: 'slackApi' }]);
const trigger = getByTestId('tool-credential-picker-trigger-connect');
expect(trigger.textContent?.toLowerCase()).toContain('connect');
});
it('emits select-credential with (item, authType, credentialId) on row click', async () => {
const { getByTestId, findByTestId, emitted } = render(
baseNodeItem,
[{ authType: 'slackApi' }],
[{ id: 'c-1', name: 'My Slack', type: 'slackApi' }],
);
await fireEvent.click(getByTestId('tool-credential-picker-trigger-connect'));
const row = await findByTestId('tool-credential-picker-row');
await fireEvent.click(row);
const events = emitted()['select-credential'];
expect(events?.[0]).toEqual([baseNodeItem, 'slackApi', 'c-1']);
});
it('renders a single trigger even when the item accepts multiple auth types', () => {
const { getAllByTestId } = render(baseNodeItem, [
{ authType: 'googleApi' },
{ authType: 'gmailOAuth2' },
]);
expect(getAllByTestId('tool-credential-picker-trigger-connect')).toHaveLength(1);
});
});

View File

@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest';
import { fireEvent } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import ToolRow from '../ToolRow.vue';
import type { McpServerConnectionItem, NodeConnectionItem, WorkflowConnectionItem } from '../types';
const renderRow = createComponentRenderer(ToolRow);
function render(item: McpServerConnectionItem | NodeConnectionItem | WorkflowConnectionItem) {
return renderRow({ props: { item }, pinia: createTestingPinia() });
}
const baseMcp: McpServerConnectionItem = {
id: 'mcp-1',
kind: 'mcp-server',
title: 'Notion',
description: 'Connect to Notion',
isConnected: false,
availableTools: [],
};
const baseNode: NodeConnectionItem = {
id: 'node-1',
kind: 'node',
title: 'OpenAI',
description: 'Talk to GPT',
isConnected: false,
nodeTypeName: '@n8n/n8n-nodes-langchain.openAi',
};
const baseWorkflow: WorkflowConnectionItem = {
id: 'wf-1',
kind: 'workflow',
title: 'Summariser',
isConnected: false,
workflowId: 'wf-1234',
};
describe('ToolRow', () => {
it('shows a Connect button for an available mcp-server and emits connect on click', async () => {
const { getByTestId, emitted } = render(baseMcp);
const connect = getByTestId('tools-connection-row-connect');
expect(connect.textContent).toContain('Connect');
await fireEvent.click(connect);
expect(emitted().connect?.[0]).toEqual([baseMcp]);
expect(emitted()['open-detail']).toBeUndefined();
});
it('shows a Connect button for an available node and emits connect on click', async () => {
const { getByTestId, emitted } = render(baseNode);
const connect = getByTestId('tools-connection-row-connect');
expect(connect.textContent).toContain('Connect');
await fireEvent.click(connect);
expect(emitted().connect?.[0]).toEqual([baseNode]);
expect(emitted()['open-detail']).toBeUndefined();
});
it('shows a Connect button for an available workflow', () => {
const { getByTestId } = render(baseWorkflow);
expect(getByTestId('tools-connection-row-connect')).toBeTruthy();
});
it('shows the connected badge and emits open-detail when the gear is clicked', async () => {
const connected: McpServerConnectionItem = {
...baseMcp,
isConnected: true,
credentials: [{ authType: 'mcpOAuth2Api', credentialId: 'cred-1', required: true }],
};
const { getByTestId, emitted } = render(connected);
expect(getByTestId('tools-connection-row-connected')).toBeTruthy();
await fireEvent.click(getByTestId('tools-connection-row-configure'));
expect(emitted()['open-detail']?.[0]).toEqual([connected]);
});
it('emits open-detail when the main row action is clicked', async () => {
const { getByTestId, emitted } = render(baseMcp);
await fireEvent.click(getByTestId('tools-connection-row-main'));
expect(emitted()['open-detail']?.[0]).toEqual([baseMcp]);
});
it('emits open-detail when the main row action is keyboard activated', async () => {
const { getByTestId, emitted } = render(baseMcp);
getByTestId('tools-connection-row-main').focus();
await userEvent.keyboard('{Enter}');
expect(emitted()['open-detail']?.[0]).toEqual([baseMcp]);
});
it('does not fire open-detail when clicking the Connect action', async () => {
const { getByTestId, emitted } = render(baseMcp);
await fireEvent.click(getByTestId('tools-connection-row-connect'));
expect(emitted().connect).toHaveLength(1);
expect(emitted()['open-detail']).toBeUndefined();
});
it('emits open-detail when a node row is clicked', async () => {
const { getByTestId, emitted } = render(baseNode);
await fireEvent.click(getByTestId('tools-connection-row-main'));
expect(emitted()['open-detail']?.[0]).toEqual([baseNode]);
});
it('keeps row actions as sibling interactive controls', () => {
const { getByTestId } = render(baseMcp);
expect(getByTestId('tools-connection-row').getAttribute('role')).toBeNull();
expect(getByTestId('tools-connection-row-main').tagName).toBe('BUTTON');
expect(
getByTestId('tools-connection-row-main').contains(
getByTestId('tools-connection-row-connect'),
),
).toBe(false);
});
it('renders a file-type iconSource as an N8nNodeIcon img', () => {
const item: NodeConnectionItem = {
...baseNode,
iconSource: { type: 'file', src: 'https://cdn/openai.svg' },
};
const { container } = render(item);
const img = container.querySelector('img');
expect(img).not.toBeNull();
expect(img?.getAttribute('src')).toBe('https://cdn/openai.svg');
});
it('renders an icon-type iconSource as a glyph rather than an img', () => {
const item: NodeConnectionItem = {
...baseNode,
iconSource: { type: 'icon', name: 'bolt' },
};
const { container } = render(item);
const img = container.querySelector('img');
expect(img).toBeNull();
});
it('renders the placeholder icon when iconSource is absent', () => {
const { container } = render(baseNode);
const img = container.querySelector('img');
expect(img).toBeNull();
});
});

View File

@ -0,0 +1,298 @@
import { beforeEach, describe, it, expect, vi } from 'vitest';
import { fireEvent, waitFor } from '@testing-library/vue';
import { createComponentRenderer, renderComponent } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
const scrollToKeyMock = vi.hoisted(() => vi.fn());
// N8nDialog teleports out of the tree (Reka UI's DialogPortal) and
// N8nRecycleScroller virtualises by offsetHeight which is 0 in jsdom. Replace
// both with render-all pass-throughs so rows are inspectable inline.
vi.mock('@n8n/design-system', async () => {
const actual = await vi.importActual<typeof import('@n8n/design-system')>('@n8n/design-system');
const N8nDialog = {
name: 'N8nDialog',
props: ['open', 'size', 'header'],
emits: ['update:open'],
template: `
<div v-if="open" role="dialog">
<h2>{{ header }}</h2>
<slot />
</div>
`,
};
const N8nRecycleScroller = {
name: 'N8nRecycleScroller',
props: ['items', 'itemSize', 'itemKey'],
methods: {
scrollToKey: scrollToKeyMock,
},
template: `
<div>
<div v-for="item in items" :key="item[itemKey]">
<slot :item="item" :update-item-size="() => {}" />
</div>
</div>
`,
};
return { ...actual, N8nDialog, N8nRecycleScroller };
});
import ToolsConnectionModal from '../ToolsConnectionModal.vue';
import McpToolSettingsContent from '../McpToolSettingsContent.vue';
import { connectedMcpFixture, makeLargeMcpList, realisticItems } from '../fixtures';
import type { SectionKey, ToolConnectionItem } from '../types';
const renderModal = createComponentRenderer(ToolsConnectionModal);
const ALL_SECTIONS: SectionKey[] = ['connected', 'nodes', 'workflows'];
beforeEach(() => {
scrollToKeyMock.mockClear();
});
function renderWith(
props: Partial<{
items: ToolConnectionItem[];
sections: SectionKey[];
detailItem: ToolConnectionItem | null;
detailMode: 'detail' | 'settings';
}>,
) {
return renderModal({
props: {
open: true,
items: props.items ?? realisticItems,
sections: props.sections ?? ALL_SECTIONS,
detailItem: props.detailItem ?? null,
detailMode: props.detailMode,
},
pinia: createTestingPinia(),
});
}
function renderWithMcpSettingsSlot(detailItem: ToolConnectionItem) {
const Host = {
components: { ToolsConnectionModal, McpToolSettingsContent },
props: ['detailItem'],
template: `
<ToolsConnectionModal
:open="true"
:items="[]"
:sections="[]"
:detail-item="detailItem"
detail-mode="settings"
>
<template #settings-body="{ item, onSave, onDisconnect }">
<McpToolSettingsContent
v-if="item.kind === 'mcp-server'"
:item="item"
@save="onSave"
@disconnect="onDisconnect"
/>
</template>
</ToolsConnectionModal>
`,
};
return renderComponent(Host, {
props: { detailItem },
pinia: createTestingPinia(),
});
}
describe('ToolsConnectionModal', () => {
it('renders only the sections passed via the sections prop', () => {
const { queryAllByTestId, queryByText } = renderWith({
sections: ['connected', 'nodes'],
});
expect(queryByText('Notion')).toBeTruthy();
expect(queryByText('GitHub')).toBeTruthy();
expect(queryByText('OpenAI')).toBeTruthy();
expect(queryByText('Notion onboarding flow')).toBeNull();
const headers = queryAllByTestId('tools-connection-section-header').map((el) => el.textContent);
expect(headers.some((t) => t?.includes('Connect to a service'))).toBe(true);
expect(headers.some((t) => t?.includes('Connected'))).toBe(false);
});
it('renders items from every configured section', () => {
const { queryByText } = renderWith({ sections: ALL_SECTIONS });
expect(queryByText('Notion')).toBeTruthy();
expect(queryByText('GitHub')).toBeTruthy();
expect(queryByText('OpenAI')).toBeTruthy();
expect(queryByText('Notion onboarding flow')).toBeTruthy();
});
it('keeps connected items in the nodes section when the connected section is omitted', () => {
const { queryByText, queryAllByText } = renderWith({
sections: ['nodes'],
});
expect(queryByText('Notion')).toBeTruthy();
expect(queryByText('Slack')).toBeTruthy();
expect(queryByText('GitHub')).toBeTruthy();
expect(queryAllByText('Connected').length).toBeGreaterThan(0);
});
it('shows the empty state when items is empty', () => {
const { getByTestId } = renderWith({ items: [] });
expect(getByTestId('tools-connection-empty')).toBeTruthy();
});
it('renders the detail view when a detailItem is set', () => {
const unconnectedMcp = { ...connectedMcpFixture, isConnected: false, settings: undefined };
const { queryByTestId, queryByText, queryAllByTestId } = renderWith({
detailItem: unconnectedMcp,
});
expect(queryByTestId('tools-connection-detail')).toBeTruthy();
const chips = queryAllByTestId('tools-connection-detail-tool');
expect(chips.length).toBeGreaterThan(0);
expect(queryByText('search')).toBeTruthy();
expect(queryByText('create-pages')).toBeTruthy();
expect(queryByTestId('tools-connection-search')).toBeNull();
});
it('routes detailItem to the settings view when detailMode is settings', () => {
const { queryByTestId } = renderWith({
detailItem: connectedMcpFixture,
detailMode: 'settings',
});
expect(queryByTestId('tools-connection-settings')).toBeTruthy();
expect(queryByTestId('tools-connection-detail')).toBeNull();
});
it('renders the slotted settings body when a consumer supplies #settings-body', () => {
const { queryByTestId } = renderWithMcpSettingsSlot(connectedMcpFixture);
expect(queryByTestId('tools-connection-settings')).toBeTruthy();
expect(queryByTestId('tools-connection-settings-inclusion')).toBeTruthy();
expect(queryByTestId('tools-connection-settings-save')).toBeTruthy();
expect(queryByTestId('tools-connection-settings-disconnect')).toBeTruthy();
});
it('renders an empty settings body when no #settings-body slot is supplied', () => {
const { queryByTestId } = renderWith({
detailItem: connectedMcpFixture,
detailMode: 'settings',
});
expect(queryByTestId('tools-connection-settings')).toBeTruthy();
expect(queryByTestId('tools-connection-settings-inclusion')).toBeNull();
expect(queryByTestId('tools-connection-settings-save')).toBeNull();
});
it('shows the tab strip when at least two sections have rows', () => {
const { queryByTestId } = renderWith({
sections: ['nodes', 'workflows'],
});
expect(queryByTestId('tools-connection-tabs')).toBeTruthy();
});
it('hides tabs when sections only span one tab category', async () => {
const { queryByTestId, getByPlaceholderText } = renderWith({
sections: ['nodes'],
});
expect(queryByTestId('tools-connection-tabs')).toBeNull();
const inputEl = getByPlaceholderText('Search all tools...') as HTMLInputElement;
await fireEvent.update(inputEl, 'notion');
await waitFor(() => {
expect(queryByTestId('tools-connection-tabs')).toBeNull();
});
});
it('hides tabs when no configured section has rows', () => {
const { queryByTestId } = renderWith({
items: [],
sections: ['nodes', 'workflows'],
});
expect(queryByTestId('tools-connection-tabs')).toBeNull();
});
it('keeps all matching sections visible after a tab click', async () => {
const { queryByText, getByPlaceholderText, getByTestId } = renderWith({
sections: ['nodes', 'workflows'],
});
const inputEl = getByPlaceholderText('Search all tools...') as HTMLInputElement;
await fireEvent.update(inputEl, 'notion');
const workflowsTab = await waitFor(() => getByTestId('tools-connection-tab-workflows'));
const servicesTab = getByTestId('tools-connection-tab-services');
expect(queryByText('Notion onboarding flow')).toBeTruthy();
await fireEvent.click(workflowsTab);
expect(queryByText('Notion onboarding flow')).toBeTruthy();
expect(scrollToKeyMock).toHaveBeenLastCalledWith('header:workflows');
await fireEvent.click(servicesTab);
expect(queryByText('Notion onboarding flow')).toBeTruthy();
expect(scrollToKeyMock).toHaveBeenLastCalledWith('header:nodes');
});
it('focuses the search input when the modal opens', async () => {
const { getByPlaceholderText } = renderWith({ sections: ['nodes'] });
const inputEl = getByPlaceholderText('Search all tools...') as HTMLInputElement;
await waitFor(() => {
expect(document.activeElement).toBe(inputEl);
});
});
it('emits update:detailItem(null) when the back button is clicked', async () => {
const unconnectedMcp = { ...connectedMcpFixture, isConnected: false, settings: undefined };
const { getByTestId, emitted } = renderWith({ detailItem: unconnectedMcp });
await fireEvent.click(getByTestId('tools-connection-detail-back'));
expect(emitted()['update:detailItem']).toBeTruthy();
expect(emitted()['update:detailItem']?.[0]).toEqual([null]);
});
it('emits open-detail when a row is clicked', async () => {
const { getAllByTestId, emitted } = renderWith({ sections: ['nodes'] });
const rows = getAllByTestId('tools-connection-row-main');
await fireEvent.click(rows[0]);
expect(emitted()['open-detail']).toBeTruthy();
expect(emitted()['update:detailItem']).toBeTruthy();
});
it('forwards connect when a row connect button is clicked', async () => {
const { getAllByTestId, emitted } = renderWith({ sections: ['nodes'] });
await fireEvent.click(getAllByTestId('tools-connection-row-connect')[0]);
expect(emitted().connect).toBeTruthy();
expect(emitted()['open-detail']).toBeUndefined();
expect(emitted()['update:detailItem']).toBeUndefined();
});
it('debounces the search query before filtering rows', async () => {
const { getByPlaceholderText, queryByText } = renderWith({ sections: ['nodes'] });
const inputEl = getByPlaceholderText('Search all tools...') as HTMLInputElement;
await fireEvent.update(inputEl, 'gmail');
await waitFor(() => {
expect(queryByText('Gmail')).toBeTruthy();
expect(queryByText('GitHub')).toBeNull();
});
});
it('feeds every flattened row through to the scroller', async () => {
const items = makeLargeMcpList(300);
const { getAllByTestId } = renderWith({ items, sections: ['nodes'] });
await waitFor(() => {
const rendered = getAllByTestId('tools-connection-row');
expect(rendered.length).toBe(300);
});
});
});

View File

@ -0,0 +1,363 @@
import type {
AgentConnectionItem,
DataStoreConnectionItem,
McpServerConnectionItem,
NodeConnectionItem,
ToolConnectionItem,
WorkflowConnectionItem,
} from './types';
const ICON = {
notion: 'https://upload.wikimedia.org/wikipedia/commons/4/45/Notion_app_logo.png',
slack: 'https://upload.wikimedia.org/wikipedia/commons/d/d5/Slack_icon_2019.svg',
github: 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
gmail: 'https://upload.wikimedia.org/wikipedia/commons/7/7e/Gmail_icon_%282020%29.svg',
linear: 'https://cdn.simpleicons.org/linear/5E6AD2',
googleDrive:
'https://upload.wikimedia.org/wikipedia/commons/1/12/Google_Drive_icon_%282020%29.svg',
openai: 'https://upload.wikimedia.org/wikipedia/commons/0/04/ChatGPT_logo.svg',
gemini: 'https://upload.wikimedia.org/wikipedia/commons/8/8a/Google_Gemini_logo.svg',
googleSheets:
'https://upload.wikimedia.org/wikipedia/commons/3/30/Google_Sheets_logo_%282014-2020%29.svg',
} as const;
const connectedNotion: McpServerConnectionItem = {
id: 'mcp-notion',
kind: 'mcp-server',
title: 'Notion',
description: 'Connect to the Notion MCP Server',
iconSource: { type: 'file', src: ICON.notion },
isConnected: true,
credentials: [{ authType: 'mcpOAuth2Api', credentialId: 'cred-notion-1', required: true }],
settings: {
inclusionMode: 'except',
selectedTools: [],
excludedTools: ['notion.update-database'],
},
longDescription:
'Notion MCP helps you plug tools into your Notion workspace, allowing you to create, edit, search and organize content directly from n8n. Get contextual and relevant assistance from n8n, while keeping knowledge organized in Notion.',
publisher: { name: 'Notion', url: 'https://www.notion.so' },
version: '1.24',
docsUrl: 'https://developers.notion.com/',
availableTools: [
{
id: 'notion.search',
name: 'search',
category: 'read',
description: 'Search across the connected workspace.',
},
{
id: 'notion.fetch',
name: 'fetch',
category: 'read',
description: 'Fetch a page or block by id.',
},
{
id: 'notion.read-page',
name: 'read-page',
category: 'read',
description: 'Read the contents of a Notion page.',
},
{
id: 'notion.move-pages',
name: 'move-pages',
category: 'read',
description: 'Move pages between parents.',
},
{
id: 'notion.duplicate-page',
name: 'duplicate-page',
category: 'read',
description: 'Duplicate a page.',
},
{
id: 'notion.query-database',
name: 'query-database',
category: 'read',
description: 'Query rows from a Notion database.',
},
{
id: 'notion.create-pages',
name: 'create-pages',
category: 'write',
description: 'Create new pages.',
},
{
id: 'notion.update-page',
name: 'update-page',
category: 'write',
description: 'Update properties on a Notion page.',
},
{
id: 'notion.create-database',
name: 'create-database',
category: 'write',
description: 'Create a new database.',
},
{
id: 'notion.update-database',
name: 'update-database',
category: 'write',
description: 'Update a database schema.',
},
],
};
const connectedSlack: McpServerConnectionItem = {
id: 'mcp-slack',
kind: 'mcp-server',
title: 'Slack',
description: 'Connect to the Slack MCP Server',
iconSource: { type: 'file', src: ICON.slack },
isConnected: true,
credentials: [{ authType: 'httpBearerAuth', credentialId: 'cred-slack-1', required: true }],
longDescription:
'The Slack MCP server connects an n8n agent to a Slack workspace so it can read recent channel history and post messages on behalf of a user.',
publisher: { name: 'Slack', url: 'https://slack.com' },
version: '0.9',
docsUrl: 'https://api.slack.com/',
availableTools: [
{ id: 'slack.list-channels', name: 'list-channels', category: 'read' },
{ id: 'slack.history', name: 'channel-history', category: 'read' },
{ id: 'slack.send-message', name: 'send-message', category: 'write' },
],
};
const availableGithub: McpServerConnectionItem = {
id: 'mcp-github',
kind: 'mcp-server',
title: 'GitHub',
description: 'Connect to the GitHub MCP Server',
iconSource: { type: 'file', src: ICON.github },
isConnected: false,
credentials: [{ authType: 'mcpOAuth2Api', required: true }],
longDescription:
'The GitHub MCP server lets agents triage issues, draft pull requests, and run common repository workflows from inside n8n.',
publisher: { name: 'GitHub', url: 'https://github.com' },
version: '0.3',
docsUrl: 'https://docs.github.com/en/rest',
availableTools: [
{ id: 'github.list-issues', name: 'list-issues', category: 'read' },
{ id: 'github.get-pr', name: 'get-pr', category: 'read' },
{ id: 'github.create-pr', name: 'create-pr', category: 'write' },
],
};
const availableGmail: McpServerConnectionItem = {
id: 'mcp-gmail',
kind: 'mcp-server',
title: 'Gmail',
description: 'Connect to the Gmail MCP Server',
iconSource: { type: 'file', src: ICON.gmail },
isConnected: false,
credentials: [{ authType: 'mcpOAuth2Api', required: true }],
availableTools: [
{ id: 'gmail.send', name: 'send-email', category: 'write' },
{ id: 'gmail.search', name: 'search', category: 'read' },
],
};
const availableLinear: McpServerConnectionItem = {
id: 'mcp-linear',
kind: 'mcp-server',
title: 'Linear',
description: 'Connect to the Linear MCP Server',
iconSource: { type: 'file', src: ICON.linear },
isConnected: false,
credentials: [{ authType: 'mcpOAuth2Api', required: true }],
availableTools: [
{ id: 'linear.list-issues', name: 'list-issues', category: 'read' },
{ id: 'linear.create-issue', name: 'create-issue', category: 'write' },
],
};
const availableGoogleDrive: McpServerConnectionItem = {
id: 'mcp-google-drive',
kind: 'mcp-server',
title: 'Google Drive',
description: 'Connect to the Google Drive MCP Server',
iconSource: { type: 'file', src: ICON.googleDrive },
isConnected: false,
credentials: [{ authType: 'mcpOAuth2Api', required: true }],
availableTools: [{ id: 'drive.list-files', name: 'list-files', category: 'read' }],
};
const availableHttp: McpServerConnectionItem = {
id: 'mcp-http',
kind: 'mcp-server',
title: 'HTTP Request',
description: 'Connect to the HTTP Request MCP Server',
isConnected: false,
credentials: [{ authType: 'httpBearerAuth', required: true }],
availableTools: [{ id: 'http.fetch', name: 'fetch', category: 'read' }],
};
const availableOpenAi: NodeConnectionItem = {
id: 'node-openai',
kind: 'node',
title: 'OpenAI',
description: 'Message an assistant or GPT, analyze images, generate audio, etc.',
iconSource: { type: 'file', src: ICON.openai },
isConnected: false,
nodeTypeName: '@n8n/n8n-nodes-langchain.openAi',
credentials: [{ authType: 'openAiApi', required: true }],
longDescription:
"Talk to OpenAI from inside an agent run — message an assistant, transcribe audio, generate images, or call any of OpenAI's chat/completion endpoints. Best for one-off LLM calls inside multi-step flows; for the agent's primary model use the Model section instead.",
};
const availableMultiCredentialHttp: NodeConnectionItem = {
id: 'node-http-multi',
kind: 'node',
title: 'HTTP Request',
description: 'Make HTTP requests with OAuth2 or a bearer token.',
isConnected: false,
nodeTypeName: 'n8n-nodes-base.httpRequestTool',
credentials: [
{ authType: 'oAuth2Api', required: false },
{ authType: 'httpBearerAuth', required: false },
],
};
const availableGemini: NodeConnectionItem = {
id: 'node-gemini',
kind: 'node',
title: 'Google Gemini',
description: 'Interact with Google Gemini AI models.',
iconSource: { type: 'file', src: ICON.gemini },
isConnected: false,
nodeTypeName: '@n8n/n8n-nodes-langchain.googleGemini',
};
const availableGoogleSheets: NodeConnectionItem = {
id: 'node-google-sheets',
kind: 'node',
title: 'Google Sheets Tool',
description: 'Read, update and write data to Google Sheets.',
iconSource: { type: 'file', src: ICON.googleSheets },
isConnected: false,
nodeTypeName: 'n8n-nodes-base.googleSheetsTool',
longDescription:
'Read or write rows in a Google Sheet. The agent can append new rows, look up data by query, update existing values, and delete rows when asked. Best for tabular data that needs to be shared with a team or kept in sync with other Sheets-based tools.',
credentials: [
{ authType: 'googleApi', required: true },
{ authType: 'googleSheetsOAuth2Api', required: false },
],
};
const availableAgentCodeReviewer: AgentConnectionItem = {
id: 'agent-code-reviewer',
kind: 'agent',
title: 'Code Reviewer',
description: 'Reviews diffs and flags issues before merge.',
isConnected: false,
agentId: 'agent-1001',
};
const availableAgentDocWriter: AgentConnectionItem = {
id: 'agent-doc-writer',
kind: 'agent',
title: 'Doc Writer',
description: 'Drafts release notes from changelog entries.',
isConnected: false,
agentId: 'agent-1002',
};
const availableDataCustomers: DataStoreConnectionItem = {
id: 'data-customers',
kind: 'data-store',
title: 'Customers database',
description: 'Read or write customer rows and their related orders.',
isConnected: false,
dataStoreId: 'ds-customers',
};
const availableDataSalesCsv: DataStoreConnectionItem = {
id: 'data-sales-csv',
kind: 'data-store',
title: 'Sales CSV',
description: 'Monthly export of closed deals — read-only.',
isConnected: false,
dataStoreId: 'ds-sales-csv',
};
const availableWorkflowSummariser: WorkflowConnectionItem = {
id: 'workflow-summariser',
kind: 'workflow',
title: 'Notion onboarding flow',
isConnected: false,
workflowId: 'wf-1234',
longDescription:
'Walks a new employee through their first day in Notion — fetches their profile, creates a starter task list, and posts a welcome message to the team channel.',
};
const availableWorkflowEmailParser: WorkflowConnectionItem = {
id: 'workflow-email-parser',
kind: 'workflow',
title: 'Notion data extraction',
isConnected: false,
workflowId: 'wf-5678',
};
export const realisticItems: ToolConnectionItem[] = [
connectedNotion,
connectedSlack,
availableGithub,
availableGmail,
availableLinear,
availableGoogleDrive,
availableHttp,
availableOpenAi,
availableMultiCredentialHttp,
availableGemini,
availableGoogleSheets,
availableAgentCodeReviewer,
availableAgentDocWriter,
availableDataCustomers,
availableDataSalesCsv,
availableWorkflowSummariser,
availableWorkflowEmailParser,
];
export const connectedMcpFixture = connectedNotion;
export const sampleCredentials = [
makeFixtureCredential('cred-notion-1', 'mcpOAuth2Api', "Jake's Notion"),
makeFixtureCredential('cred-notion-2', 'mcpOAuth2Api', "Giulio's Notion"),
makeFixtureCredential('cred-notion-3', 'mcpOAuth2Api', "Bob's Notion"),
makeFixtureCredential('cred-notion-4', 'mcpOAuth2Api', "Rob's Notion"),
makeFixtureCredential('cred-slack-1', 'httpBearerAuth', "Jake's Slack token"),
makeFixtureCredential('cred-slack-2', 'httpBearerAuth', 'Workspace Slack token'),
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function makeFixtureCredential(id: string, type: string, name: string): any {
const now = new Date().toISOString();
return {
id,
name,
type,
data: {},
createdAt: now,
updatedAt: now,
isManaged: false,
};
}
export function makeLargeMcpList(count: number): McpServerConnectionItem[] {
return Array.from({ length: count }, (_, index) => ({
id: `mcp-generated-${index}`,
kind: 'mcp-server' as const,
title: `Synthetic MCP Server #${index + 1}`,
description: `Connect to a fabricated MCP server for virtualization testing (${index + 1}).`,
isConnected: false,
credentials: [{ authType: 'httpBearerAuth', required: true }],
availableTools: [
{
id: `mcp-generated-${index}.echo`,
name: 'echo',
category: 'read' as const,
description: 'Returns its input.',
},
],
}));
}

View File

@ -0,0 +1,5 @@
import type { ToolConnectionItem, ToolIconSource } from './types';
export function resolveToolItemIcon(item: ToolConnectionItem): ToolIconSource | null {
return item.iconSource ?? null;
}

View File

@ -0,0 +1,120 @@
import type { InjectionKey } from 'vue';
export type ConnectionItemKind = 'node' | 'workflow' | 'mcp-server' | 'agent' | 'data-store';
export type ToolIconSource =
| { type: 'file'; src: string }
| { type: 'icon'; name: string; color?: string };
export interface ToolCredentialRef {
authType: string;
credentialId?: string;
required?: boolean;
}
export interface BaseConnectionItem {
id: string;
title: string;
description?: string;
iconSource?: ToolIconSource;
isConnected: boolean;
credentials?: ToolCredentialRef[];
longDescription?: string;
}
export interface NodeConnectionItem extends BaseConnectionItem {
kind: 'node';
nodeTypeName: string;
}
export interface WorkflowConnectionItem extends BaseConnectionItem {
kind: 'workflow';
workflowId: string;
}
export interface McpServerTool {
id: string;
name: string;
description?: string;
/** Partitions tools into READ TOOLS / WRITE TOOLS chips in the detail view. */
category?: 'read' | 'write';
}
export interface PublisherInfo {
name: string;
url?: string;
}
export type McpToolInclusionMode = 'all' | 'selected' | 'except';
export interface McpToolSettings {
inclusionMode: McpToolInclusionMode;
selectedTools: string[];
excludedTools: string[];
}
export type ToolConnectionSettings = McpToolSettings;
export interface McpServerConnectionItem extends BaseConnectionItem {
kind: 'mcp-server';
availableTools: McpServerTool[];
settings?: McpToolSettings;
publisher?: PublisherInfo;
version?: string;
docsUrl?: string;
}
export interface AgentConnectionItem extends BaseConnectionItem {
kind: 'agent';
agentId: string;
}
export interface DataStoreConnectionItem extends BaseConnectionItem {
kind: 'data-store';
dataStoreId: string;
}
export type ToolConnectionItem =
| NodeConnectionItem
| WorkflowConnectionItem
| McpServerConnectionItem
| AgentConnectionItem
| DataStoreConnectionItem;
export type SectionKey = 'connected' | 'nodes' | 'agents' | 'data' | 'workflows';
export type TabId = 'services' | 'agents' | 'data' | 'workflows';
export const SECTION_TAB: Record<SectionKey, TabId> = {
connected: 'services',
nodes: 'services',
agents: 'agents',
data: 'data',
workflows: 'workflows',
};
export const TAB_ORDER: TabId[] = ['services', 'agents', 'data', 'workflows'];
export type FlattenedRow =
| { kind: 'section-header'; key: string; section: SectionKey; title: string; count: number }
| { kind: 'item'; key: string; section: SectionKey; item: ToolConnectionItem };
export interface PickableCredential {
id: string;
name: string;
type: string;
}
/**
* Read-only credentials lookup + "create new" trigger. Injected by each
* consumer at the modal mount site so the shared module doesn't import
* editor-ui stores (which would break Storybook's dev-server bundling).
*/
export interface ToolConnectionCredentialAdapter {
getCredentialsByType: (authType: string) => readonly PickableCredential[];
openNewCredential: (authType: string) => void;
}
export const TOOL_CONNECTION_CREDENTIAL_ADAPTER_KEY = Symbol(
'tool-connection-credential-adapter',
) as InjectionKey<ToolConnectionCredentialAdapter | null>;