mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
feat(editor): Standardize Agent Builder modals (#39220)
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: n8n:agent-design-language
|
||||
description: Design or review Agent Builder interfaces. Use for Agent configuration patterns, modal flows, responsive behavior, status treatments, and other Agent-only UI work.
|
||||
---
|
||||
|
||||
# Agent design language
|
||||
|
||||
Use this skill only for interface work in
|
||||
`packages/frontend/editor-ui/src/features/agents`.
|
||||
|
||||
Before you change code:
|
||||
|
||||
1. Read `packages/frontend/editor-ui/src/features/agents/AGENTS.md`.
|
||||
2. Read `packages/frontend/editor-ui/src/features/agents/agents-design-language.md`
|
||||
completely.
|
||||
3. Use [ui-design](../ui-design/SKILL.md) for visual implementation rules.
|
||||
4. Use [content-design](../content-design/SKILL.md) when you add or change UI
|
||||
text.
|
||||
|
||||
Keep Agent-specific components in the Agent feature. Do not change the Design
|
||||
System unless the user explicitly asks for this change.
|
||||
|
||||
Update the design language document when you establish a reusable Agent
|
||||
interface pattern. Add or update tests for the behavior that you change.
|
||||
@@ -0,0 +1 @@
|
||||
../../../../.agents/skills/agent-design-language
|
||||
@@ -7963,8 +7963,9 @@
|
||||
"agents.list.actions.duplicate": "Duplicate",
|
||||
"agents.list.availableInMCP": "Available in MCP",
|
||||
"agents.list.readonly": "Read only",
|
||||
"agents.duplicate.modal.name": "Duplicate Agent",
|
||||
"agents.duplicate.modal.name": "Duplicate agent",
|
||||
"agents.duplicate.modal.enterName": "Enter a name for the duplicated agent",
|
||||
"agents.duplicate.modal.nameRequired": "Enter a name.",
|
||||
"agents.duplicate.modal.success": "Agent duplicated",
|
||||
"agents.duplicate.modal.error": "Could not duplicate agent",
|
||||
"agents.duplicate.modal.unconfigured": "Configure the agent before duplicating it",
|
||||
@@ -8047,6 +8048,8 @@
|
||||
"agents.tools.workflow.disabled.noSupportedTrigger": "Needs a '{trigger}' trigger",
|
||||
"agents.tools.workflow.notPublished": "Not published",
|
||||
"agents.tools.workflow.search.placeholder": "Search workflows",
|
||||
"agents.tools.workflow.empty.title": "No workflows available",
|
||||
"agents.tools.workflow.empty.noResults": "No matching workflows",
|
||||
"agents.tools.workflow.createFailed.title": "Couldn't create workflow",
|
||||
"agents.tools.workflow.createFailed.message": "Try creating the workflow again.",
|
||||
"agents.tools.workflow.fetchFailed.title": "Couldn't check workflow compatibility",
|
||||
@@ -8077,8 +8080,7 @@
|
||||
"agents.toolConfig.workflow.inputs.value.placeholder.boolean": "true or false",
|
||||
"agents.toolConfig.workflow.inputs.value.placeholder.array": "Enter a JSON array, e.g. [\"a\", \"b\"]",
|
||||
"agents.toolConfig.workflow.inputs.value.placeholder.object": "Enter a JSON object, e.g. {'{'}\"key\": \"value\"{'}'}",
|
||||
"agents.toolConfig.save": "Save",
|
||||
"agents.toolConfig.cancel": "Cancel",
|
||||
"agents.toolConfig.validation.fixFields": "Fix the highlighted fields before saving.",
|
||||
"agents.toolConfig.approval.label": "Require approval",
|
||||
"agents.toolConfig.approval.hint": "Pause before this tool runs until a human approves it.",
|
||||
"agents.toolConfig.mcpApproval.label": "Require approval",
|
||||
@@ -8206,21 +8208,19 @@
|
||||
"agents.builder.vectorStores.panel.empty.title": "No vector stores connected",
|
||||
"agents.builder.vectorStores.panel.empty.description": "Connect a vector store so this agent can search data you've already indexed.",
|
||||
"agents.builder.vectorStores.panel.edit": "Edit",
|
||||
"agents.builder.vectorStores.panel.remove": "Remove connection",
|
||||
"agents.builder.vectorStores.panel.removeModal.title": "Remove {name}?",
|
||||
"agents.builder.vectorStores.panel.removeModal.description": "This removes {name} from the agent's connected vector stores.",
|
||||
"agents.builder.vectorStores.panel.removeModal.button.remove": "Remove connection",
|
||||
"agents.builder.vectorStores.panel.remove": "Remove vector store",
|
||||
"agents.builder.vectorStores.modal.title": "Connect vector store",
|
||||
"agents.builder.vectorStores.modal.defaultName": "new_vector_store",
|
||||
"agents.builder.vectorStores.modal.description": "Choose a vector database to search for relevant context during this agent's runs.",
|
||||
"agents.builder.vectorStores.modal.connect": "Connect",
|
||||
"agents.builder.vectorStores.modal.remove": "Remove connection",
|
||||
"agents.builder.vectorStores.modal.back": "Back",
|
||||
"agents.builder.vectorStores.modal.name.label": "Name",
|
||||
"agents.builder.vectorStores.modal.remove": "Remove vector store",
|
||||
"agents.builder.vectorStores.modal.name.placeholder": "product-docs",
|
||||
"agents.builder.vectorStores.modal.name.validation.required": "Enter a name.",
|
||||
"agents.builder.vectorStores.modal.name.validation.pattern": "Use only letters, numbers, underscores, and hyphens.",
|
||||
"agents.builder.vectorStores.modal.name.validation.duplicate": "A vector store with this name is already connected.",
|
||||
"agents.builder.vectorStores.modal.credential.label": "Credential",
|
||||
"agents.builder.vectorStores.modal.credential.placeholder": "Select credential",
|
||||
"agents.builder.vectorStores.modal.credential.required": "Select a credential.",
|
||||
"agents.builder.vectorStores.modal.indexName.label": "Index name",
|
||||
"agents.builder.vectorStores.modal.indexName.placeholder": "product-docs",
|
||||
"agents.builder.vectorStores.modal.namespace.label": "Namespace",
|
||||
@@ -8233,6 +8233,7 @@
|
||||
"agents.builder.vectorStores.modal.queryName.placeholder": "match_documents",
|
||||
"agents.builder.vectorStores.modal.embeddingModel.label": "Embedding model",
|
||||
"agents.builder.vectorStores.modal.embeddingModel.hint": "Must match the model used to index the data in this store. A different model produces meaningless results, even at the same dimension.",
|
||||
"agents.builder.vectorStores.modal.embeddingModel.required": "Select an embedding model and credential.",
|
||||
"agents.builder.vectorStores.modal.embeddingModel.dimensions": "{dimensions} dimensions",
|
||||
"agents.builder.vectorStores.modal.test.successTitle": "Connected {name}",
|
||||
"agents.builder.vectorStores.modal.test.failedTitle": "Couldn't connect vector store",
|
||||
@@ -8268,16 +8269,17 @@
|
||||
"agents.builder.subAgents.modelsByDifficulty.high.description": "Complex tasks that need deeper reasoning or detailed synthesis.",
|
||||
"agents.builder.subAgents.modelsByDifficulty.clear": "Use parent model",
|
||||
"agents.builder.subAgents.modal.title": "Sub-agents",
|
||||
"agents.builder.subAgents.modal.description": "Select agents from this project to let this agent delegate work to them.",
|
||||
"agents.builder.subAgents.modal.selectAgent": "Select {name}",
|
||||
"agents.builder.subAgents.modal.empty.title": "No agents to add",
|
||||
"agents.builder.subAgents.modal.empty.description": "Agents from this project show here. Already added agents are hidden.",
|
||||
"agents.builder.subAgents.modal.empty.title": "No sub-agents available",
|
||||
"agents.builder.subAgents.modal.empty.description": "Other agents from this project show here.",
|
||||
"agents.builder.subAgents.modal.search.placeholder": "Search agents",
|
||||
"agents.builder.subAgents.modal.noResults.title": "No matching agents",
|
||||
"agents.builder.subAgents.modal.noResults.description": "Try another search term.",
|
||||
"agents.builder.subAgents.modal.cancel": "Cancel",
|
||||
"agents.builder.subAgents.modal.add": "Add agent",
|
||||
"agents.builder.subAgents.modal.remove": "Remove sub agent",
|
||||
"agents.builder.subAgents.modal.add": "Add sub-agent",
|
||||
"agents.builder.subAgents.modal.added": "Added",
|
||||
"agents.builder.subAgents.modal.addAriaLabel": "Add {name} as a sub-agent",
|
||||
"agents.builder.subAgents.modal.remove": "Remove sub-agent",
|
||||
"agents.builder.memory.recallModel.label": "Memory model",
|
||||
"agents.builder.memory.recallModel.hint": "Choose the model that creates, reviews, and retrieves memories. Uses the agent model by default.",
|
||||
"agents.builder.editor.copy": "Copy to clipboard",
|
||||
@@ -8287,6 +8289,9 @@
|
||||
"agents.builder.tools.type.workflow": "Workflow",
|
||||
"agents.builder.tools.type.custom": "Custom tool",
|
||||
"agents.builder.tools.remove": "Remove tool",
|
||||
"agents.builder.tools.workflow.remove": "Remove workflow",
|
||||
"agents.builder.tools.mcp.remove": "Remove MCP",
|
||||
"agents.builder.tools.mcp.add": "Add MCP",
|
||||
"agents.builder.tools.back": "Back to tools",
|
||||
"agents.builder.skills.title": "Skills",
|
||||
"agents.builder.skills.description": "How this agent handles specific tasks",
|
||||
@@ -8340,11 +8345,8 @@
|
||||
"agents.builder.skills.references.maxCount": "Skills can have up to {max} reference files.",
|
||||
"agents.builder.skills.references.invalidSummary": "Fix reference files before saving.",
|
||||
"agents.builder.skills.references.characterCount": "{count} / {max} characters",
|
||||
"agents.builder.skills.create.title": "Add skill",
|
||||
"agents.builder.skills.create.cancel": "Cancel",
|
||||
"agents.builder.skills.create.save": "Save skill",
|
||||
"agents.builder.skills.defaultName": "New skill",
|
||||
"agents.builder.skills.create.error": "Could not create skill",
|
||||
"agents.builder.skills.added": "Skill added",
|
||||
"agents.builder.skills.saveError": "Could not save skill",
|
||||
"agents.builder.skills.duplicateName.error": "Agent already has a skill named \"{name}\"",
|
||||
"agents.builder.skills.validation.nameRequired": "Enter a name.",
|
||||
@@ -8372,17 +8374,12 @@
|
||||
"agents.builder.tasks.status.title": "Schedule status",
|
||||
"agents.builder.tasks.status.success": "Success",
|
||||
"agents.builder.tasks.status.error": "Failed",
|
||||
"agents.builder.tasks.delete": "Delete schedule",
|
||||
"agents.builder.tasks.deleteConfirm.title": "Delete schedule?",
|
||||
"agents.builder.tasks.deleteConfirm.description": "This schedule will stop running and be removed. This can't be undone.",
|
||||
"agents.builder.tasks.deleteConfirm.confirm": "Delete",
|
||||
"agents.builder.tasks.delete": "Remove schedule",
|
||||
"agents.builder.tasks.loadError": "Could not load schedules",
|
||||
"agents.builder.tasks.deleteError": "Could not delete schedule",
|
||||
"agents.builder.tasks.create.title": "Add schedule",
|
||||
"agents.builder.tasks.edit.title": "Edit schedule",
|
||||
"agents.builder.tasks.name.label": "Name",
|
||||
"agents.builder.tasks.removeError": "Could not remove schedule",
|
||||
"agents.builder.tasks.defaultName": "New schedule",
|
||||
"agents.builder.tasks.name.placeholder": "Daily Slack summary",
|
||||
"agents.builder.tasks.objective.label": "Objective",
|
||||
"agents.builder.tasks.objective.label": "What to do",
|
||||
"agents.builder.tasks.objective.placeholder": "Summarize today's #general messages and post the summary.",
|
||||
"agents.builder.tasks.schedule.label": "Schedule",
|
||||
"agents.builder.tasks.schedule.frequency.hourly": "Hourly",
|
||||
@@ -8401,16 +8398,14 @@
|
||||
"agents.builder.tasks.schedule.nextOccurrence": "Next execution {occurrence}",
|
||||
"agents.builder.tasks.schedule.summary": "{description} · {execution}",
|
||||
"agents.builder.tasks.schedule.executionPaused": "Execution paused",
|
||||
"agents.builder.tasks.cancel": "Cancel",
|
||||
"agents.builder.tasks.save": "Save schedule",
|
||||
"agents.builder.tasks.saveError": "Could not save schedule",
|
||||
"agents.builder.tasks.execute": "Run schedule",
|
||||
"agents.builder.tasks.executeStarted": "Schedule started",
|
||||
"agents.builder.tasks.executeError": "Could not start the schedule",
|
||||
"agents.builder.tasks.validation.nameRequired": "Enter a name.",
|
||||
"agents.builder.tasks.validation.nameMaxLength": "Name must be {max} characters or fewer.",
|
||||
"agents.builder.tasks.validation.objectiveRequired": "Enter an objective.",
|
||||
"agents.builder.tasks.validation.objectiveMaxLength": "Objective must be {max} characters or fewer.",
|
||||
"agents.builder.tasks.validation.objectiveRequired": "Enter what to do.",
|
||||
"agents.builder.tasks.validation.objectiveMaxLength": "What to do must be {max} characters or fewer.",
|
||||
"agents.builder.tasks.validation.cronRequired": "Enter a schedule.",
|
||||
"agents.builder.tasks.validation.cronInvalid": "This isn't a valid cron expression",
|
||||
"agents.builder.editor.invalidJson": "Invalid JSON",
|
||||
@@ -8807,6 +8802,7 @@
|
||||
"agents.channels.modal.notRunning": "Not running",
|
||||
"agents.channels.modal.notRunning.tooltip": "This channel couldn't be started. n8n keeps retrying it.",
|
||||
"agents.channels.modal.removeChannel": "Remove channel",
|
||||
"agents.channels.modal.credentialRequired": "Select a credential.",
|
||||
"agents.channels.modal.saveChannelError": "Channel couldn't be saved. Try again.",
|
||||
"agents.channels.modal.removeChannelError": "Channel couldn't be removed. Try again.",
|
||||
"agents.channels.modal.slackAppNotDeleted.title": "Slack app wasn't removed",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Agent frontend
|
||||
|
||||
Read `agents-design-language.md` before you change Agent interface behavior or
|
||||
visuals. It is the source of truth for reusable Agent-specific patterns.
|
||||
|
||||
Keep feature-specific components in this module. Use the shared Design System
|
||||
for primitives. Do not change the Design System for an Agent-only need unless
|
||||
the user explicitly asks for this change.
|
||||
|
||||
For modal work, use `components/modals/AgentModal.vue`. Use
|
||||
`components/modals/AgentModalMultiStep.vue` when the user selects an item before
|
||||
they configure it.
|
||||
@@ -3028,6 +3028,63 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('closes the preview when opening the AI panel would make the editor too narrow', async () => {
|
||||
localStorage.setItem('N8N_AGENT_PREVIEW_OPEN:p1:a1', 'true');
|
||||
const wrapper = await renderView();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
builderContainerWidth: number;
|
||||
toggleAiPanel: () => void;
|
||||
};
|
||||
vm.builderContainerWidth = 1100;
|
||||
await nextTick();
|
||||
expect(wrapper.findComponent({ name: 'AgentPreviewDock' }).props('isOpen')).toBe(true);
|
||||
|
||||
vm.toggleAiPanel();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'AgentPreviewDock' }).props('isOpen')).toBe(false);
|
||||
});
|
||||
|
||||
it('closes the AI panel when opening the preview would make the editor too narrow', async () => {
|
||||
localStorage.setItem('N8N_AGENT_AI_PANEL_OPEN:p1:a1', 'true');
|
||||
const wrapper = await renderView();
|
||||
(wrapper.vm as unknown as { builderContainerWidth: number }).builderContainerWidth = 1100;
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
|
||||
|
||||
await (wrapper.vm as unknown as { onOpenPreview: () => Promise<boolean> }).onOpenPreview();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findComponent({ name: 'AgentPreviewDock' }).props('isOpen')).toBe(true);
|
||||
expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shrinks both side panels before closing either one', async () => {
|
||||
localStorage.setItem('N8N_AGENT_PREVIEW_OPEN:p1:a1', 'true');
|
||||
const wrapper = await renderView();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
builderContainerWidth: number;
|
||||
toggleAiPanel: () => void;
|
||||
};
|
||||
vm.builderContainerWidth = 1200;
|
||||
await nextTick();
|
||||
|
||||
vm.toggleAiPanel();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'AgentPreviewDock' }).props('isOpen')).toBe(true);
|
||||
const builder = wrapper.get('[data-testid="agent-builder-container"]').element as HTMLElement;
|
||||
const aiWidth = Number.parseFloat(builder.style.getPropertyValue('--agent-ai-panel-width'));
|
||||
const previewWidth = Number.parseFloat(
|
||||
builder.style.getPropertyValue('--agent-preview-chat-column-width'),
|
||||
);
|
||||
expect(aiWidth).toBeGreaterThanOrEqual(320);
|
||||
expect(previewWidth).toBeGreaterThanOrEqual(320);
|
||||
expect(aiWidth + previewWidth).toBeCloseTo(720);
|
||||
});
|
||||
|
||||
it('routes to the assistant setup instead of opening the panel when setup is unfinished', async () => {
|
||||
instanceAiReadyRef.value = false;
|
||||
const wrapper = await renderView();
|
||||
@@ -4291,7 +4348,7 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
expect(wrapper.findComponent({ name: 'AgentSkillsSection' }).props('skills')).toEqual([]);
|
||||
});
|
||||
|
||||
it('opens the add skill modal and applies the created skill', async () => {
|
||||
it('adds a skill without a duplicate config save or success toast', async () => {
|
||||
const skill = {
|
||||
name: 'Summarize Meetings',
|
||||
description: 'Use when summarizing meeting notes',
|
||||
@@ -4318,6 +4375,7 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
);
|
||||
|
||||
const wrapper = await renderView();
|
||||
fetchConfigMock.mockClear();
|
||||
wrapper.findComponent({ name: 'AgentSkillsSection' }).vm.$emit('add-skill');
|
||||
await nextTick();
|
||||
|
||||
@@ -4346,10 +4404,9 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
expect(wrapper.findComponent({ name: 'AgentSkillsSection' }).props('skills')).toEqual([
|
||||
{ id: 'skill_0Ab9ZkLm3Pq7Xy2N', skill },
|
||||
]);
|
||||
expect(showMessageMock).toHaveBeenCalledWith({
|
||||
title: 'agents.builder.skills.added',
|
||||
type: 'success',
|
||||
});
|
||||
expect(fetchConfigMock).toHaveBeenCalledWith('p1', 'a1');
|
||||
expect(updateConfigMock).not.toHaveBeenCalled();
|
||||
expect(showMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies skill modal edits to the local config and agent resource', async () => {
|
||||
|
||||
+56
-3
@@ -40,6 +40,16 @@ vi.mock('@/app/stores/ui.store', () => ({
|
||||
useUIStore: () => ({ openModalWithData: openModalWithDataSpy }),
|
||||
}));
|
||||
|
||||
const createAgentSpy = vi.fn();
|
||||
vi.mock('../composables/useCreateAgent', () => ({
|
||||
useCreateAgent: () => ({ createAgent: createAgentSpy }),
|
||||
}));
|
||||
|
||||
const canCreateAgentRef = ref(true);
|
||||
vi.mock('../composables/useAgentPermissions', () => ({
|
||||
useAgentPermissions: () => ({ canCreate: canCreateAgentRef }),
|
||||
}));
|
||||
|
||||
const showErrorSpy = vi.fn();
|
||||
vi.mock('@n8n/composables/useToast', () => ({
|
||||
useToast: () => ({ showError: showErrorSpy }),
|
||||
@@ -164,6 +174,7 @@ describe('AgentCapabilitiesSection', () => {
|
||||
ensureProjectAgentsLoadedSpy.mockImplementation(async () => projectAgentsListRef.value ?? []);
|
||||
refreshProjectAgentsSpy.mockImplementation(async () => projectAgentsListRef.value ?? []);
|
||||
integrationsCatalogRef.value = [];
|
||||
canCreateAgentRef.value = true;
|
||||
});
|
||||
|
||||
it('renders web search and forwards its config updates', () => {
|
||||
@@ -390,16 +401,44 @@ describe('AgentCapabilitiesSection', () => {
|
||||
name: AGENT_SUB_AGENTS_MODAL_KEY,
|
||||
data: expect.objectContaining({
|
||||
agents: [
|
||||
{ id: 'agent-3', name: 'Research Agent' },
|
||||
{ id: 'agent-4', name: 'Draft Agent' },
|
||||
{
|
||||
id: 'agent-2',
|
||||
name: 'Helper Agent',
|
||||
added: true,
|
||||
useWhen: 'Use for billing support requests.',
|
||||
invalidReasons: [],
|
||||
agentHref: '/projects/project-id/agents/agent-2',
|
||||
},
|
||||
{
|
||||
id: 'agent-3',
|
||||
name: 'Research Agent',
|
||||
added: false,
|
||||
useWhen: undefined,
|
||||
invalidReasons: [],
|
||||
agentHref: '/projects/project-id/agents/agent-3',
|
||||
},
|
||||
{
|
||||
id: 'agent-4',
|
||||
name: 'Draft Agent',
|
||||
added: false,
|
||||
useWhen: undefined,
|
||||
invalidReasons: [],
|
||||
agentHref: '/projects/project-id/agents/agent-4',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const modalCall = openModalWithDataSpy.mock.calls[0]?.[0] as {
|
||||
data: { onConfirm: (payload: { agentId: string; useWhen?: string }) => void };
|
||||
data: {
|
||||
onCreateAgent?: () => void;
|
||||
onConfirm: (payload: { agentId: string; useWhen?: string }) => void;
|
||||
};
|
||||
};
|
||||
modalCall.data.onCreateAgent?.();
|
||||
expect(createAgentSpy).toHaveBeenCalledWith('button', 'project-id');
|
||||
|
||||
modalCall.data.onConfirm({
|
||||
agentId: 'agent-4',
|
||||
useWhen: 'Use for draft research requests.',
|
||||
@@ -418,6 +457,20 @@ describe('AgentCapabilitiesSection', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits agent creation when the project does not allow it', async () => {
|
||||
canCreateAgentRef.value = false;
|
||||
const wrapper = mountSection([], {}, null, [], [makeAgent()]);
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="agent-capabilities-add-sub-agent"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const modalCall = openModalWithDataSpy.mock.calls[0]?.[0] as {
|
||||
data: { onCreateAgent?: () => void };
|
||||
};
|
||||
expect(modalCall.data.onCreateAgent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refreshes a stale project-agent cache and renders only the sub-agent name', async () => {
|
||||
const child = makeAgent({ id: 'agent-new', name: 'Notion Research Agent' });
|
||||
refreshProjectAgentsSpy.mockImplementationOnce(async () => {
|
||||
|
||||
+5
-2
@@ -33,7 +33,7 @@ function mountItem(
|
||||
N8nDropdownMenu: {
|
||||
template: '<div data-testid="channel-actions"><slot name="trigger" /></div>',
|
||||
},
|
||||
N8nIcon: { template: '<i />' },
|
||||
N8nIcon: { props: ['icon'], template: '<i :data-icon="icon" />' },
|
||||
N8nText: { template: '<span><slot /></span>' },
|
||||
N8nTooltip: {
|
||||
props: ['content', 'disabled'],
|
||||
@@ -54,8 +54,11 @@ describe('AgentChannelListItem', () => {
|
||||
|
||||
expect(wrapper.text()).toContain(label);
|
||||
expect(wrapper.find('[data-testid="agent-channel-connected-indicator"]').exists()).toBe(
|
||||
connected,
|
||||
configured,
|
||||
);
|
||||
if (configured) {
|
||||
expect(wrapper.find('[data-icon="check"]').exists()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
describe('a channel that failed to start', () => {
|
||||
|
||||
+46
-17
@@ -253,7 +253,7 @@ function mountModal(view: ChannelView = 'example_setup', isPublished = false) {
|
||||
'runtimeError',
|
||||
'connectAction',
|
||||
],
|
||||
emits: ['setup', 'disconnect'],
|
||||
emits: ['setup', 'edit'],
|
||||
template: `
|
||||
<li
|
||||
data-testid="channel-list-item"
|
||||
@@ -264,7 +264,7 @@ function mountModal(view: ChannelView = 'example_setup', isPublished = false) {
|
||||
:data-runtime-error="runtimeError"
|
||||
>
|
||||
<button data-testid="setup-channel" @click="$emit('setup', integration.type)" />
|
||||
<button data-testid="disconnect-channel" @click="$emit('disconnect', integration.type)" />
|
||||
<button data-testid="edit-channel" @click="$emit('edit', integration.type)" />
|
||||
</li>
|
||||
`,
|
||||
},
|
||||
@@ -349,8 +349,8 @@ describe('AgentChannelModal', () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('[data-testid="channel-list-item"]').attributes()).toMatchObject({
|
||||
// Still set up, so the row keeps its Edit/Disconnect menu rather than
|
||||
// offering to connect a channel that already exists.
|
||||
// Still set up, so the row keeps its Edit action instead of offering to
|
||||
// connect a channel that already exists.
|
||||
'data-configured': 'true',
|
||||
'data-connected': 'false',
|
||||
'data-not-running': 'true',
|
||||
@@ -415,9 +415,10 @@ describe('AgentChannelModal', () => {
|
||||
const release = deferPersistence();
|
||||
statuses.value.example = 'configured';
|
||||
connectedCredentials.value.example = 'credential-old';
|
||||
selectedCredentials.value.example = 'credential-new';
|
||||
const wrapper = mountModal('example_edit');
|
||||
const wrapper = mountModal('list');
|
||||
await flushPromises();
|
||||
await wrapper.get('[data-testid="edit-channel"]').trigger('click');
|
||||
await wrapper.get('[data-testid="select-credential"]').trigger('click');
|
||||
|
||||
await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click');
|
||||
await flushPromises();
|
||||
@@ -425,9 +426,7 @@ describe('AgentChannelModal', () => {
|
||||
await wrapper.get('[data-testid="close-dialog"]').trigger('click');
|
||||
expect(wrapper.emitted('update:open')).toBeUndefined();
|
||||
|
||||
expect(
|
||||
wrapper.get('[data-testid="agent-channel-back"]').attributes('disabled'),
|
||||
).toBeDefined();
|
||||
expect(wrapper.get('[data-testid="agent-modal-back"]').attributes('disabled')).toBeDefined();
|
||||
|
||||
await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click');
|
||||
expect(mocks.disconnect).not.toHaveBeenCalled();
|
||||
@@ -609,10 +608,11 @@ describe('AgentChannelModal', () => {
|
||||
statuses.value.example = 'connected';
|
||||
connectedCredentials.value.example = 'credential-old';
|
||||
integrationApproval.value.example = { mode: 'global' };
|
||||
const wrapper = mountModal('example_edit');
|
||||
const wrapper = mountModal('list');
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[data-testid="agent-channel-back"]').trigger('click');
|
||||
await wrapper.get('[data-testid="edit-channel"]').trigger('click');
|
||||
await wrapper.get('[data-testid="agent-modal-back"]').trigger('click');
|
||||
await wrapper
|
||||
.get('[data-testid="channel-list-item"]:last-child [data-testid="setup-channel"]')
|
||||
.trigger('click');
|
||||
@@ -686,15 +686,13 @@ describe('AgentChannelModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the same managed removal confirmation from the list menu', async () => {
|
||||
statuses.value.example = 'connected';
|
||||
it('confirms managed removal from the edit view and removes the Slack app when selected', async () => {
|
||||
statuses.value.example = 'configured';
|
||||
connectedCredentials.value.example = 'credential-managed';
|
||||
const wrapper = mountModal('list', true);
|
||||
const wrapper = mountModal('example_edit', true);
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[data-testid="disconnect-channel"]').trigger('click');
|
||||
expect(mocks.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click');
|
||||
await wrapper.get('[data-testid="confirm-delete-app"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
@@ -703,6 +701,37 @@ describe('AgentChannelModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not expose removal from the channel list', async () => {
|
||||
statuses.value.example = 'connected';
|
||||
connectedCredentials.value.example = 'credential-managed';
|
||||
const wrapper = mountModal('list', true);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="disconnect-channel"]').exists()).toBe(false);
|
||||
expect(mocks.disconnect).not.toHaveBeenCalled();
|
||||
expect(wrapper.find('[data-testid="disconnect-confirmation"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows no Back action when editing a channel directly', async () => {
|
||||
connectedCredentials.value.example = 'credential-old';
|
||||
const wrapper = mountModal('example_edit');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="agent-modal-back"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows an inline error when Save has no selected credential', async () => {
|
||||
const wrapper = mountModal('example_edit');
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click');
|
||||
|
||||
expect(wrapper.get('[data-testid="agent-channel-credential-required"]').text()).toBe(
|
||||
'agents.channels.modal.credentialRequired',
|
||||
);
|
||||
expect(mocks.connect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disconnects managed credentials without confirmation when the agent is unpublished', async () => {
|
||||
statuses.value.example = 'configured';
|
||||
connectedCredentials.value.example = 'credential-managed';
|
||||
|
||||
+12
-16
@@ -8,6 +8,7 @@ import { useUIStore } from '@/app/stores/ui.store';
|
||||
|
||||
import AgentDuplicateModal from '../components/AgentDuplicateModal.vue';
|
||||
import type { AgentDuplicateModalData } from '../components/AgentDuplicateModal.vue';
|
||||
import { AgentModalTestStub } from './utils/AgentModalTestStub';
|
||||
|
||||
// Components use `data-testid`; the global setup configures `data-test-id`.
|
||||
configure({ testIdAttribute: 'data-testid' });
|
||||
@@ -18,11 +19,7 @@ vi.mock('@n8n/i18n', () => {
|
||||
});
|
||||
|
||||
const stubs = {
|
||||
Modal: {
|
||||
props: ['name', 'width', 'closeOnClickModal', 'closeOnPressEscape', 'showClose'],
|
||||
template:
|
||||
'<div role="dialog" :data-close-on-click-modal="closeOnClickModal" :data-close-on-press-escape="closeOnPressEscape" :data-show-close="showClose"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
|
||||
},
|
||||
AgentModal: AgentModalTestStub,
|
||||
N8nHeading: { template: '<h2><slot /></h2>' },
|
||||
N8nText: { template: '<span v-bind="$attrs"><slot /></span>' },
|
||||
N8nButton: {
|
||||
@@ -80,21 +77,23 @@ describe('AgentDuplicateModal', () => {
|
||||
expect(getByTestId('agent-duplicate-name-input')).toHaveValue('Support Agent (copy)');
|
||||
});
|
||||
|
||||
it('disables confirm and shows a name-taken error when the name matches an existing agent', async () => {
|
||||
it('keeps confirm available and shows a name-taken error when the name matches', async () => {
|
||||
const { getByTestId, getByText } = renderModal();
|
||||
|
||||
await fireEvent.update(getByTestId('agent-duplicate-name-input'), 'Support Agent');
|
||||
|
||||
expect(getByText('agents.duplicate.modal.button.nameTaken')).toBeInTheDocument();
|
||||
expect(getByTestId('agent-duplicate-confirm')).toBeDisabled();
|
||||
expect(getByTestId('agent-duplicate-confirm')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables confirm when the name is empty', async () => {
|
||||
const { getByTestId } = renderModal();
|
||||
it('reveals the error when confirming an empty name', async () => {
|
||||
const { getByTestId, getByText, onConfirm } = renderModal();
|
||||
|
||||
await fireEvent.update(getByTestId('agent-duplicate-name-input'), ' ');
|
||||
await fireEvent.click(getByTestId('agent-duplicate-confirm'));
|
||||
|
||||
expect(getByTestId('agent-duplicate-confirm')).toBeDisabled();
|
||||
expect(getByText('agents.duplicate.modal.nameRequired')).toBeInTheDocument();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onConfirm with the trimmed name and closes the modal', async () => {
|
||||
@@ -133,16 +132,13 @@ describe('AgentDuplicateModal', () => {
|
||||
const { getByTestId, getByRole } = renderModal({ onConfirm });
|
||||
|
||||
const dialog = getByRole('dialog');
|
||||
// Before submit, dismissal is allowed.
|
||||
expect(dialog).toHaveAttribute('data-show-close', 'true');
|
||||
expect(dialog).toHaveAttribute('data-busy', 'false');
|
||||
|
||||
await fireEvent.update(getByTestId('agent-duplicate-name-input'), 'Triage Bot');
|
||||
await fireEvent.click(getByTestId('agent-duplicate-confirm'));
|
||||
|
||||
await waitFor(() => expect(onConfirm).toHaveBeenCalled());
|
||||
// While submitting, all dismissal paths are disabled.
|
||||
expect(dialog).toHaveAttribute('data-show-close', 'false');
|
||||
expect(dialog).toHaveAttribute('data-close-on-click-modal', 'false');
|
||||
expect(dialog).toHaveAttribute('data-close-on-press-escape', 'false');
|
||||
expect(dialog).toHaveAttribute('data-busy', 'true');
|
||||
expect(getByTestId('dialog-close-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
+4
-8
@@ -9,6 +9,7 @@ const closeModalMock = vi.fn();
|
||||
vi.mock('@/app/stores/ui.store', () => ({
|
||||
useUIStore: () => ({
|
||||
closeModal: closeModalMock,
|
||||
modalsById: { agentJsonImportModal: { open: true } },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -27,13 +28,8 @@ vi.mock('@n8n/i18n', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/components/Modal.vue', () => ({
|
||||
default: {
|
||||
name: 'Modal',
|
||||
template:
|
||||
'<section data-testid="agent-json-import-modal"><slot name="header" /><slot name="content" /><slot name="footer" /></section>',
|
||||
props: ['name', 'width', 'customClass'],
|
||||
},
|
||||
vi.mock('../components/modals/AgentModal.vue', async () => ({
|
||||
default: (await import('./utils/AgentModalTestStub')).AgentModalTestStub,
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/design-system', () => ({
|
||||
@@ -123,7 +119,7 @@ describe('AgentJsonImportModal', () => {
|
||||
);
|
||||
});
|
||||
|
||||
await wrapper.findAll('button')[0].trigger('click');
|
||||
await wrapper.get('[data-testid="dialog-close-button"]').trigger('click');
|
||||
|
||||
expect(wrapper.text()).not.toContain('agent.json');
|
||||
expect(wrapper.find('[data-testid="agent-json-import-confirm"]').attributes('disabled')).toBe(
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AgentModal from '../components/modals/AgentModal.vue';
|
||||
import AgentModalMultiStep from '../components/modals/AgentModalMultiStep.vue';
|
||||
|
||||
vi.mock('@n8n/design-system', async () => {
|
||||
const { defineComponent, nextTick, onMounted, ref } = await import('vue');
|
||||
return {
|
||||
N8nDialog: defineComponent({
|
||||
props: {
|
||||
open: Boolean,
|
||||
size: String,
|
||||
trapFocus: Boolean,
|
||||
disableOutsidePointerEvents: Boolean,
|
||||
},
|
||||
emits: ['update:open', 'escapeKeyDown', 'interactOutside', 'openAutoFocus'],
|
||||
setup(_, { emit }) {
|
||||
const dialog = ref<HTMLElement>();
|
||||
const escapePrevented = ref(false);
|
||||
onMounted(() => {
|
||||
const event = new Event('open-auto-focus', { cancelable: true });
|
||||
emit('openAutoFocus', event);
|
||||
if (!event.defaultPrevented) {
|
||||
void nextTick(() =>
|
||||
dialog.value?.querySelector<HTMLElement>('button:not([disabled])')?.focus(),
|
||||
);
|
||||
}
|
||||
});
|
||||
function pressEscape() {
|
||||
const event = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true });
|
||||
emit('escapeKeyDown', event);
|
||||
escapePrevented.value = event.defaultPrevented;
|
||||
}
|
||||
return { dialog, escapePrevented, pressEscape };
|
||||
},
|
||||
template: `
|
||||
<section
|
||||
ref="dialog"
|
||||
v-if="open"
|
||||
role="dialog"
|
||||
:data-size="size"
|
||||
:data-trap-focus="trapFocus"
|
||||
:data-disable-outside-pointer-events="disableOutsidePointerEvents"
|
||||
:data-escape-prevented="escapePrevented"
|
||||
>
|
||||
<slot />
|
||||
<button data-test-id="dialog-escape" @click="pressEscape" />
|
||||
<button data-test-id="dialog-dismiss" @click="$emit('update:open', false)" />
|
||||
</section>
|
||||
`,
|
||||
}),
|
||||
N8nDialogHeader: defineComponent({ template: '<header><slot /></header>' }),
|
||||
N8nDialogFooter: defineComponent({ template: '<footer><slot /></footer>' }),
|
||||
N8nDialogTitle: defineComponent({ template: '<div><slot /></div>' }),
|
||||
N8nButton: defineComponent({
|
||||
props: ['disabled'],
|
||||
emits: ['click'],
|
||||
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
|
||||
}),
|
||||
N8nInlineTextEdit: defineComponent({
|
||||
props: ['modelValue', 'disabled'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
data-test-id="agent-modal-title-input"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
N8nIcon: defineComponent({ template: '<i />' }),
|
||||
N8nIconButton: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: ['disabled', 'icon'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button v-bind="$attrs" :data-icon="icon" :disabled="disabled" @click="$emit(\'click\')" />',
|
||||
}),
|
||||
N8nText: defineComponent({ template: '<span v-bind="$attrs"><slot /></span>' }),
|
||||
};
|
||||
});
|
||||
|
||||
function mountModal(
|
||||
props: Partial<InstanceType<typeof AgentModal>['$props']> = {},
|
||||
slots: Record<string, string> = { default: '<input data-test-id="first-body-field" />' },
|
||||
) {
|
||||
return mount(AgentModal, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
open: true,
|
||||
title: 'Tool settings',
|
||||
...props,
|
||||
},
|
||||
slots,
|
||||
});
|
||||
}
|
||||
|
||||
describe('AgentModal', () => {
|
||||
it('uses the large agent size and infers footer visibility from its slots', () => {
|
||||
const withoutFooter = mountModal();
|
||||
expect(withoutFooter.get('[role="dialog"]').attributes('data-size')).toBe('2xlarge');
|
||||
expect(withoutFooter.text()).toContain('Tool settings');
|
||||
expect(withoutFooter.find('footer').exists()).toBe(false);
|
||||
withoutFooter.unmount();
|
||||
|
||||
const withFooter = mountModal({}, { footerActions: '<button>Save</button>' });
|
||||
expect(withFooter.get('footer').text()).toContain('Save');
|
||||
withFooter.unmount();
|
||||
|
||||
const hiddenFooter = mountModal(
|
||||
{ showFooter: false },
|
||||
{ footerActions: '<button>Save</button>' },
|
||||
);
|
||||
expect(hiddenFooter.find('footer').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves inferred footer visibility through the multi-step shell', () => {
|
||||
const wrapper = mount(AgentModalMultiStep, {
|
||||
props: {
|
||||
open: true,
|
||||
step: 'configure',
|
||||
title: 'Configure tool',
|
||||
},
|
||||
slots: {
|
||||
footerActions: '<button>Save</button>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.get('footer').text()).toContain('Save');
|
||||
});
|
||||
|
||||
it.each(['select', 'list'])('keeps the outer modal body fixed for the %s picker step', (step) => {
|
||||
const wrapper = mount(AgentModalMultiStep, {
|
||||
props: {
|
||||
open: true,
|
||||
step,
|
||||
title: 'Add asset',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.getComponent(AgentModal).props('bodyScrollable')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the outer modal body scrollable for configuration steps', () => {
|
||||
const wrapper = mount(AgentModalMultiStep, {
|
||||
props: {
|
||||
open: true,
|
||||
step: 'configure',
|
||||
title: 'Configure asset',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.getComponent(AgentModal).props('bodyScrollable')).toBe(true);
|
||||
});
|
||||
|
||||
it('edits the title and focuses the first body field', async () => {
|
||||
const wrapper = mountModal({ editableTitle: true });
|
||||
await flushPromises();
|
||||
|
||||
const title = wrapper.get<HTMLInputElement>('[data-test-id="agent-modal-title-input"]');
|
||||
await title.setValue('Slack messages');
|
||||
|
||||
expect(wrapper.emitted('update:title')).toEqual([['Slack messages']]);
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-test-id="first-body-field"]').element);
|
||||
});
|
||||
|
||||
it('uses the dialog fallback focus when the body has no interactive control', async () => {
|
||||
const wrapper = mountModal({}, { default: '<span>Delete this agent?</span>' });
|
||||
await flushPromises();
|
||||
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-testid="dialog-close-button"]').element);
|
||||
});
|
||||
|
||||
it('blocks parent dismissal while a nested credential dialog is open', async () => {
|
||||
const wrapper = mountModal({ trapFocus: false, showBack: true });
|
||||
|
||||
await wrapper.get('[data-test-id="dialog-escape"]').trigger('click');
|
||||
await wrapper.get('[data-test-id="dialog-dismiss"]').trigger('click');
|
||||
|
||||
expect(wrapper.get('[role="dialog"]').attributes('data-escape-prevented')).toBe('true');
|
||||
expect(wrapper.get('[data-testid="agent-modal-back"]').attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.get('[data-testid="dialog-close-button"]').attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.emitted('update:open')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits Back and Close when the modal is idle', async () => {
|
||||
const wrapper = mountModal({ showBack: true });
|
||||
|
||||
await wrapper.get('[data-testid="agent-modal-back"]').trigger('click');
|
||||
await wrapper.get('[data-testid="dialog-close-button"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('back')).toHaveLength(1);
|
||||
expect(wrapper.emitted('update:open')).toEqual([[false]]);
|
||||
});
|
||||
|
||||
it('keeps Back, Close, and dismiss disabled while busy', async () => {
|
||||
const wrapper = mountModal({ showBack: true, busy: true });
|
||||
|
||||
expect(wrapper.get('[data-testid="agent-modal-back"]').attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.get('[data-testid="dialog-close-button"]').attributes('disabled')).toBeDefined();
|
||||
await wrapper.get('[data-test-id="dialog-dismiss"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('back')).toBeUndefined();
|
||||
expect(wrapper.emitted('update:open')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import AgentSkillModal from '../components/AgentSkillModal.vue';
|
||||
import type { AgentSkill } from '../types';
|
||||
import { AgentModalTestStub } from './utils/AgentModalTestStub';
|
||||
|
||||
vi.mock('@n8n/i18n', () => {
|
||||
const i18n = { baseText: (key: string) => key };
|
||||
@@ -28,17 +29,6 @@ vi.mock('@n8n/composables/useToast', () => ({
|
||||
useToast: () => ({ showMessage }),
|
||||
}));
|
||||
|
||||
const ModalStub = defineComponent({
|
||||
props: ['name', 'customClass', 'width'],
|
||||
template: `
|
||||
<div role="dialog">
|
||||
<slot name="header" />
|
||||
<slot name="content" />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
const SkillViewerStub = defineComponent({
|
||||
emits: ['import:skill', 'update:skill', 'update:valid'],
|
||||
props: ['skill', 'selectedPath', 'showValidationWarnings', 'errors', 'scrollable'],
|
||||
@@ -60,7 +50,14 @@ const SkillViewerStub = defineComponent({
|
||||
() => emit('update:valid', computeValid()),
|
||||
);
|
||||
return () =>
|
||||
h('div', { 'data-testid': 'agent-skill-viewer-stub' }, [h('span', props.selectedPath)]);
|
||||
h('div', { 'data-testid': 'agent-skill-viewer-stub' }, [
|
||||
h('span', props.selectedPath),
|
||||
h(
|
||||
'span',
|
||||
{ 'data-testid': 'agent-skill-instructions-error' },
|
||||
props.errors?.instructions ?? '',
|
||||
),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -71,16 +68,18 @@ function renderModal({
|
||||
skill,
|
||||
skillId,
|
||||
availableTools,
|
||||
existingSkillNames,
|
||||
}: {
|
||||
onConfirm?: (payload: { id?: string; skill: AgentSkill }) => void;
|
||||
skill?: AgentSkill;
|
||||
skillId?: string;
|
||||
availableTools?: Array<{ name: string; label: string }>;
|
||||
existingSkillNames?: string[];
|
||||
} = {}) {
|
||||
const renderComponent = createComponentRenderer(AgentSkillModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Modal: ModalStub,
|
||||
AgentModal: AgentModalTestStub,
|
||||
AgentSkillViewer: SkillViewerStub,
|
||||
N8nButton: {
|
||||
template: '<button v-bind="$attrs" :disabled="disabled"><slot /></button>',
|
||||
@@ -102,6 +101,7 @@ function renderModal({
|
||||
skill,
|
||||
skillId,
|
||||
availableTools,
|
||||
existingSkillNames,
|
||||
onConfirm,
|
||||
},
|
||||
},
|
||||
@@ -119,11 +119,26 @@ describe('AgentSkillModal', () => {
|
||||
uiStore.closeModal = vi.fn();
|
||||
});
|
||||
|
||||
it('does not call createAgentSkill when the user cancels before saving', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const { getByText } = renderModal({ onConfirm });
|
||||
it('uses the wider fit-content Agent modal width', () => {
|
||||
const { container } = renderModal();
|
||||
|
||||
await fireEvent.click(getByText('agents.builder.skills.create.cancel'));
|
||||
expect(container.querySelector('[data-testid="agent-skill-modal"]')).toHaveAttribute(
|
||||
'data-size',
|
||||
'fit',
|
||||
);
|
||||
expect(container.querySelector('[data-testid="agent-skill-modal"]')).toHaveAttribute(
|
||||
'data-body-flush',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call createAgentSkill when the user closes before saving', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const { container } = renderModal({ onConfirm });
|
||||
|
||||
await fireEvent.click(
|
||||
container.querySelector('[data-testid="dialog-close-button"]') as Element,
|
||||
);
|
||||
|
||||
expect(apiCreateSpy).not.toHaveBeenCalled();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
@@ -148,11 +163,20 @@ describe('AgentSkillModal', () => {
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(uiStore.closeModal).not.toHaveBeenCalled();
|
||||
expect(showMessage).toHaveBeenCalledWith({
|
||||
title: 'agents.builder.skills.saveError',
|
||||
message: 'agents.builder.skills.validation.descriptionRequired',
|
||||
type: 'error',
|
||||
expect(showMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the next available default name for a new skill', () => {
|
||||
const { container } = renderModal({
|
||||
existingSkillNames: [
|
||||
'agents.builder.skills.defaultName',
|
||||
'agents.builder.skills.defaultName 2',
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="agent-modal-title-input"]')).toHaveValue(
|
||||
'agents.builder.skills.defaultName 3',
|
||||
);
|
||||
});
|
||||
|
||||
it('explains why overlong instructions cannot be saved', async () => {
|
||||
@@ -172,11 +196,10 @@ describe('AgentSkillModal', () => {
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(uiStore.closeModal).not.toHaveBeenCalled();
|
||||
expect(showMessage).toHaveBeenCalledWith({
|
||||
title: 'agents.builder.skills.saveError',
|
||||
message: 'agents.builder.skills.validation.instructionsMaxLength',
|
||||
type: 'error',
|
||||
});
|
||||
expect(showMessage).not.toHaveBeenCalled();
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-skill-instructions-error"]'),
|
||||
).toHaveTextContent('agents.builder.skills.validation.instructionsMaxLength');
|
||||
});
|
||||
|
||||
it('adds and removes references from the file navigation', async () => {
|
||||
|
||||
+152
-36
@@ -11,15 +11,18 @@ vi.mock('@n8n/i18n', () => ({
|
||||
baseText: (key: string, options?: { interpolate?: Record<string, string> }) =>
|
||||
({
|
||||
'agents.builder.subAgents.modal.title': 'Sub-agents',
|
||||
'agents.builder.subAgents.modal.description': 'Select published agents',
|
||||
'agents.builder.subAgents.modal.selectAgent': `Select ${options?.interpolate?.name ?? ''}`,
|
||||
'agents.builder.subAgents.modal.empty.title': 'No agents to add',
|
||||
'agents.builder.subAgents.modal.empty.title': 'No sub-agents available',
|
||||
'agents.builder.subAgents.modal.empty.description': 'Published agents show here',
|
||||
'agents.builder.subAgents.modal.search.placeholder': 'Search agents',
|
||||
'agents.builder.subAgents.modal.noResults.title': 'No matching agents',
|
||||
'agents.builder.subAgents.modal.noResults.description': 'Try another search term.',
|
||||
'agents.builder.subAgents.modal.add': 'Add agent',
|
||||
'agents.builder.subAgents.modal.remove': 'Remove sub agent',
|
||||
'agents.builder.subAgents.modal.add': 'Add sub-agent',
|
||||
'agents.builder.subAgents.modal.added': 'Added',
|
||||
'agents.builder.subAgents.modal.addAriaLabel': `Add ${options?.interpolate?.name ?? ''} as a sub-agent`,
|
||||
'agents.builder.subAgents.modal.remove': 'Remove sub-agent',
|
||||
'projects.header.create.agent': 'Create agent',
|
||||
'projectRoles.agent:create.tooltip': 'Create new agents',
|
||||
'agents.builder.subAgents.useWhen.label': 'When should this agent be used?',
|
||||
'agents.builder.subAgents.useWhen.hint': 'Tell the parent agent when to delegate work.',
|
||||
'agents.builder.subAgents.useWhen.placeholder': 'Use for billing questions',
|
||||
@@ -33,15 +36,66 @@ vi.mock('@n8n/i18n', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/ui.store', () => ({
|
||||
useUIStore: () => ({ closeModal: closeModalMock }),
|
||||
useUIStore: () => ({
|
||||
closeModal: closeModalMock,
|
||||
modalsById: { agentSubAgentsModal: { open: true } },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/components/Modal.vue', () => ({
|
||||
vi.mock('../components/modals/AgentModalMultiStep.vue', async () => ({
|
||||
default: (await import('./utils/AgentModalTestStub')).AgentModalMultiStepTestStub,
|
||||
}));
|
||||
|
||||
vi.mock('@/features/shared/toolsConnection/ToolsConnectionModal.vue', () => ({
|
||||
default: {
|
||||
name: 'Modal',
|
||||
props: ['name', 'width', 'customClass'],
|
||||
template:
|
||||
'<section><header><slot name="header" /></header><main><slot name="content" /></main><footer><slot name="footer" /></footer></section>',
|
||||
name: 'ToolsConnectionModal',
|
||||
props: [
|
||||
'items',
|
||||
'categories',
|
||||
'detailItem',
|
||||
'searchPlaceholder',
|
||||
'emptyMessage',
|
||||
'noResultsMessage',
|
||||
'createAction',
|
||||
'connectLabel',
|
||||
'connectAriaLabel',
|
||||
'connectedLabel',
|
||||
],
|
||||
emits: ['connect', 'open-detail', 'create'],
|
||||
template: `
|
||||
<div>
|
||||
<button
|
||||
v-if="createAction"
|
||||
data-testid="agent-sub-agents-modal-create"
|
||||
@click="$emit('create')"
|
||||
>
|
||||
<span>{{ createAction.label }}</span>
|
||||
<span data-testid="agent-sub-agents-modal-create-description">{{ createAction.description }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="items.length === 0"
|
||||
data-testid="agent-sub-agents-modal-empty"
|
||||
>{{ emptyMessage }}</div>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
data-testid="agent-sub-agents-modal-row"
|
||||
>
|
||||
<span>{{ item.title }}</span>
|
||||
<button
|
||||
v-if="item.status === 'connected'"
|
||||
data-testid="agent-sub-agents-modal-added"
|
||||
@click="$emit('open-detail', item)"
|
||||
>{{ connectedLabel(item) }}</button>
|
||||
<button
|
||||
v-else
|
||||
:aria-label="connectAriaLabel(item)"
|
||||
data-testid="agent-sub-agents-modal-add"
|
||||
@click="$emit('connect', item)"
|
||||
>{{ connectLabel(item) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -101,7 +155,8 @@ describe('AgentSubAgentsModal', () => {
|
||||
|
||||
const addButtons = wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]');
|
||||
expect(addButtons).toHaveLength(2);
|
||||
expect(addButtons[0].text()).toBe('Add agent');
|
||||
expect(addButtons[0].text()).toBe('Add sub-agent');
|
||||
expect(addButtons[1].attributes('aria-label')).toBe('Add Research Agent as a sub-agent');
|
||||
|
||||
await addButtons[1].trigger('click');
|
||||
|
||||
@@ -117,7 +172,9 @@ describe('AgentSubAgentsModal', () => {
|
||||
await wrapper.find('[data-testid="agent-sub-agents-modal-use-when"]').setValue('a'.repeat(513));
|
||||
expect(
|
||||
wrapper.find('[data-testid="agent-sub-agents-modal-confirm"]').attributes('disabled'),
|
||||
).toBeDefined();
|
||||
).toBeUndefined();
|
||||
await confirmButton.trigger('click');
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
|
||||
await wrapper
|
||||
.find('[data-testid="agent-sub-agents-modal-use-when"]')
|
||||
@@ -154,7 +211,49 @@ describe('AgentSubAgentsModal', () => {
|
||||
expect(closeModalMock).toHaveBeenCalledWith('agentSubAgentsModal');
|
||||
});
|
||||
|
||||
it('filters available agents by name before configuring one', async () => {
|
||||
it('keeps an added sub-agent visible and opens its existing configuration', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
const wrapper = mount(AgentSubAgentsModal, {
|
||||
props: {
|
||||
modalName: 'agentSubAgentsModal',
|
||||
data: {
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-2',
|
||||
name: 'Billing Agent',
|
||||
added: true,
|
||||
useWhen: 'Use for invoice questions.',
|
||||
},
|
||||
],
|
||||
onConfirm,
|
||||
onRemove,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const addedButton = wrapper.find('[data-testid="agent-sub-agents-modal-added"]');
|
||||
expect(addedButton.text()).toBe('Added');
|
||||
expect(wrapper.find('[data-testid="agent-sub-agents-modal-add"]').exists()).toBe(false);
|
||||
|
||||
await addedButton.trigger('click');
|
||||
|
||||
expect(wrapper.find('h2').text()).toBe('Billing Agent');
|
||||
expect(wrapper.find('[data-testid="agent-sub-agents-modal-use-when"]').element).toHaveProperty(
|
||||
'value',
|
||||
'Use for invoice questions.',
|
||||
);
|
||||
expect(wrapper.find('[data-testid="agent-sub-agents-modal-remove"]').exists()).toBe(true);
|
||||
|
||||
await wrapper.find('[data-testid="agent-sub-agents-modal-confirm"]').trigger('click');
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
agentId: 'agent-2',
|
||||
useWhen: 'Use for invoice questions.',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the agents and asset-specific copy to the shared picker', async () => {
|
||||
const wrapper = mount(AgentSubAgentsModal, {
|
||||
props: {
|
||||
modalName: 'agentSubAgentsModal',
|
||||
@@ -168,28 +267,16 @@ describe('AgentSubAgentsModal', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const searchInput = wrapper.find('[data-testid="agent-sub-agents-modal-search"]');
|
||||
expect(searchInput.exists()).toBe(true);
|
||||
const picker = wrapper.getComponent({ name: 'ToolsConnectionModal' });
|
||||
expect(picker.props('searchPlaceholder')).toBe('Search agents');
|
||||
expect(picker.props('emptyMessage')).toBe('No sub-agents available');
|
||||
expect(picker.props('noResultsMessage')).toBe('No matching agents');
|
||||
expect(picker.props('items')).toEqual([
|
||||
expect.objectContaining({ agentId: 'agent-2', title: 'Billing Agent' }),
|
||||
expect.objectContaining({ agentId: 'agent-3', title: 'Research Agent' }),
|
||||
]);
|
||||
|
||||
await searchInput.setValue(' bill ');
|
||||
|
||||
let rows = wrapper.findAll('[data-testid="agent-sub-agents-modal-row"]');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].text()).toContain('Billing Agent');
|
||||
expect(wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]')).toHaveLength(1);
|
||||
|
||||
await searchInput.setValue('unknown');
|
||||
|
||||
expect(wrapper.find('[data-testid="agent-sub-agents-modal-no-results"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="agent-sub-agents-modal-empty"]').exists()).toBe(false);
|
||||
expect(wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]')).toHaveLength(0);
|
||||
|
||||
await searchInput.setValue('RESEARCH');
|
||||
rows = wrapper.findAll('[data-testid="agent-sub-agents-modal-row"]');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].text()).toContain('Research Agent');
|
||||
|
||||
await wrapper.find('[data-testid="agent-sub-agents-modal-add"]').trigger('click');
|
||||
await wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]')[1].trigger('click');
|
||||
|
||||
expect(wrapper.find('h2').text()).toBe('Research Agent');
|
||||
});
|
||||
@@ -208,7 +295,7 @@ describe('AgentSubAgentsModal', () => {
|
||||
await wrapper.find('[data-testid="agent-sub-agents-modal-add"]').trigger('click');
|
||||
expect(wrapper.find('h2').text()).toBe('Billing Agent');
|
||||
|
||||
await wrapper.find('[data-testid="agent-sub-agents-modal-back"]').trigger('click');
|
||||
await wrapper.find('[data-testid="agent-modal-back"]').trigger('click');
|
||||
|
||||
expect(wrapper.find('h2').text()).toBe('Sub-agents');
|
||||
expect(wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]')).toHaveLength(1);
|
||||
@@ -230,7 +317,7 @@ describe('AgentSubAgentsModal', () => {
|
||||
});
|
||||
|
||||
expect(wrapper.find('h2').text()).toBe('Billing Agent');
|
||||
expect(wrapper.find('[data-testid="agent-sub-agents-modal-back"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="agent-modal-back"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="agent-sub-agents-modal-use-when"]').element).toHaveProperty(
|
||||
'value',
|
||||
'Use for invoice questions.',
|
||||
@@ -294,6 +381,35 @@ describe('AgentSubAgentsModal', () => {
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="agent-sub-agents-modal-empty"]').exists()).toBe(true);
|
||||
expect(wrapper.get('[data-testid="agent-sub-agents-modal-empty"]').text()).toBe(
|
||||
'No sub-agents available',
|
||||
);
|
||||
expect(wrapper.find('[data-testid="agent-sub-agents-modal-add"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the create agent subtitle and opens agent creation', async () => {
|
||||
const onCreateAgent = vi.fn();
|
||||
const wrapper = mount(AgentSubAgentsModal, {
|
||||
props: {
|
||||
modalName: 'agentSubAgentsModal',
|
||||
data: {
|
||||
agents: [],
|
||||
onCreateAgent,
|
||||
onConfirm: vi.fn(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.get('[data-testid="agent-sub-agents-modal-create"]').text()).toContain(
|
||||
'Create agent',
|
||||
);
|
||||
expect(wrapper.get('[data-testid="agent-sub-agents-modal-create-description"]').text()).toBe(
|
||||
'Create new agents',
|
||||
);
|
||||
|
||||
await wrapper.get('[data-testid="agent-sub-agents-modal-create"]').trigger('click');
|
||||
|
||||
expect(closeModalMock).toHaveBeenCalledWith('agentSubAgentsModal');
|
||||
expect(onCreateAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,12 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { MODAL_CONFIRM } from '@/app/constants';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
|
||||
import AgentTaskModal from '../components/AgentTaskModal.vue';
|
||||
import { formatScheduleDateTime } from '../utils/scheduleBuilder';
|
||||
import { AgentModalTestStub } from './utils/AgentModalTestStub';
|
||||
|
||||
// Components use `data-testid`; the global setup configures `data-test-id`.
|
||||
configure({ testIdAttribute: 'data-testid' });
|
||||
@@ -59,11 +59,6 @@ vi.mock('../composables/useAgentApi', () => ({
|
||||
updateAgentTask: (...args: unknown[]) => updateAgentTaskSpy(...args),
|
||||
}));
|
||||
|
||||
const confirmSpy = vi.fn();
|
||||
vi.mock('../composables/useAgentConfirmationModal', () => ({
|
||||
useAgentConfirmationModal: () => ({ openAgentConfirmationModal: confirmSpy }),
|
||||
}));
|
||||
|
||||
const MODAL_NAME = 'AgentTaskModal';
|
||||
|
||||
interface FormInputStubValidator {
|
||||
@@ -124,11 +119,7 @@ const N8nFormInputStub = defineComponent({
|
||||
|
||||
// Modal + Select/Option use filename-inferred names (no N8n prefix).
|
||||
const stubs = {
|
||||
Modal: {
|
||||
props: ['name', 'width'],
|
||||
template:
|
||||
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
|
||||
},
|
||||
AgentModal: AgentModalTestStub,
|
||||
N8nHeading: { template: '<h2><slot /></h2>' },
|
||||
N8nText: { template: '<span v-bind="$attrs"><slot /></span>' },
|
||||
N8nTooltip: { template: '<span><slot /></span>' },
|
||||
@@ -146,10 +137,10 @@ const stubs = {
|
||||
'<button v-bind="$attrs" role="switch" :aria-checked="String(modelValue)" @click="$emit(\'update:modelValue\', !modelValue)" />',
|
||||
},
|
||||
AgentPreviewButton: {
|
||||
props: ['isRunnable', 'testId', 'validationIssues'],
|
||||
props: ['isRunnable', 'testId', 'validationIssues', 'variant'],
|
||||
emits: ['open-preview'],
|
||||
template:
|
||||
'<button :data-testid="testId" :disabled="!isRunnable" @click="isRunnable && $emit(\'open-preview\')">Preview</button>',
|
||||
'<button :data-testid="testId" :data-variant="variant" :disabled="!isRunnable" @click="isRunnable && $emit(\'open-preview\')">Preview</button>',
|
||||
},
|
||||
N8nInput: {
|
||||
props: ['modelValue'],
|
||||
@@ -213,7 +204,6 @@ describe('AgentTaskModal', () => {
|
||||
uiStore = mockedStore(useUIStore);
|
||||
uiStore.openModal(MODAL_NAME);
|
||||
uiStore.closeModal = vi.fn();
|
||||
confirmSpy.mockResolvedValue(MODAL_CONFIRM);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -229,7 +219,7 @@ describe('AgentTaskModal', () => {
|
||||
'data-show-toolbar',
|
||||
'floating',
|
||||
);
|
||||
await fireEvent.update(getByTestId('agent-task-name-input'), 'My Task');
|
||||
await fireEvent.update(getByTestId('agent-modal-title-input'), 'My Task');
|
||||
await fireEvent.update(getByTestId('agent-task-objective-input'), 'Do the thing');
|
||||
await fireEvent.click(getByTestId('agent-task-save'));
|
||||
|
||||
@@ -253,7 +243,7 @@ describe('AgentTaskModal', () => {
|
||||
createAgentTaskSpy.mockResolvedValue({});
|
||||
const { getByTestId } = renderModal({ isPublished: false });
|
||||
|
||||
await fireEvent.update(getByTestId('agent-task-name-input'), 'My Task');
|
||||
await fireEvent.update(getByTestId('agent-modal-title-input'), 'My Task');
|
||||
await fireEvent.update(getByTestId('agent-task-objective-input'), 'Do the thing');
|
||||
await fireEvent.click(getByTestId('agent-task-save'));
|
||||
|
||||
@@ -278,7 +268,7 @@ describe('AgentTaskModal', () => {
|
||||
ensureAgentPersisted,
|
||||
});
|
||||
|
||||
await fireEvent.update(getByTestId('agent-task-name-input'), 'My Task');
|
||||
await fireEvent.update(getByTestId('agent-modal-title-input'), 'My Task');
|
||||
await fireEvent.update(getByTestId('agent-task-objective-input'), 'Do the thing');
|
||||
await fireEvent.click(getByTestId('agent-task-save'));
|
||||
|
||||
@@ -309,7 +299,7 @@ describe('AgentTaskModal', () => {
|
||||
ensureAgentPersisted,
|
||||
});
|
||||
|
||||
await fireEvent.update(getByTestId('agent-task-name-input'), 'My Task');
|
||||
await fireEvent.update(getByTestId('agent-modal-title-input'), 'My Task');
|
||||
await fireEvent.update(getByTestId('agent-task-objective-input'), 'Do the thing');
|
||||
await fireEvent.click(getByTestId('agent-task-save'));
|
||||
|
||||
@@ -334,7 +324,7 @@ describe('AgentTaskModal', () => {
|
||||
it('does not save when objective exceeds the maximum length', async () => {
|
||||
const { getByTestId, getByText } = renderModal();
|
||||
|
||||
await fireEvent.update(getByTestId('agent-task-name-input'), 'My Task');
|
||||
await fireEvent.update(getByTestId('agent-modal-title-input'), 'My Task');
|
||||
await fireEvent.update(
|
||||
getByTestId('agent-task-objective-input'),
|
||||
'x'.repeat(AGENT_TASK_OBJECTIVE_MAX_LENGTH + 1),
|
||||
@@ -373,7 +363,7 @@ describe('AgentTaskModal', () => {
|
||||
updateAgentTaskSpy.mockResolvedValue({});
|
||||
const { getByTestId } = renderModal({ task: makeTask() });
|
||||
|
||||
await fireEvent.update(getByTestId('agent-task-name-input'), 'Renamed');
|
||||
await fireEvent.update(getByTestId('agent-modal-title-input'), 'Renamed');
|
||||
await fireEvent.click(getByTestId('agent-task-save'));
|
||||
|
||||
await waitFor(() => expect(updateAgentTaskSpy).toHaveBeenCalled());
|
||||
@@ -490,7 +480,7 @@ describe('AgentTaskModal', () => {
|
||||
|
||||
const { getByTestId } = renderModal({ task: makeTask({ timezone: null }) });
|
||||
|
||||
await fireEvent.update(getByTestId('agent-task-name-input'), 'Renamed');
|
||||
await fireEvent.update(getByTestId('agent-modal-title-input'), 'Renamed');
|
||||
await fireEvent.click(getByTestId('agent-task-save'));
|
||||
|
||||
// Editing anything else must not pin the task, or a later change to the
|
||||
@@ -573,7 +563,7 @@ describe('AgentTaskModal', () => {
|
||||
expect(uiStore.closeModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes an existing task after confirmation', async () => {
|
||||
it('removes an existing task immediately', async () => {
|
||||
deleteAgentTaskSpy.mockResolvedValue({ success: true });
|
||||
const { getByTestId, onSaved } = renderModal({
|
||||
task: makeTask(),
|
||||
@@ -594,11 +584,17 @@ describe('AgentTaskModal', () => {
|
||||
isRunnable: true,
|
||||
});
|
||||
|
||||
expect(getByTestId('agent-task-delete')).toHaveTextContent('generic.delete');
|
||||
expect(getByTestId('agent-task-delete')).toHaveTextContent('agents.builder.tasks.delete');
|
||||
expect(getByTestId('agent-task-pause-control')).toBeInTheDocument();
|
||||
expect(getByTestId('agent-task-toggle')).toBeInTheDocument();
|
||||
expect(getByTestId('agent-task-preview')).toHaveTextContent('Preview');
|
||||
expect(getByTestId('agent-task-preview')).toHaveAttribute('data-variant', 'ghost');
|
||||
expect(getByTestId('agent-task-save')).toHaveTextContent('generic.save');
|
||||
expect(
|
||||
Array.from(getByTestId('agent-modal-footer-actions').querySelectorAll('button')).map(
|
||||
(button) => button.dataset.testid,
|
||||
),
|
||||
).toEqual(['agent-task-preview', 'agent-modal-cancel', 'agent-task-save']);
|
||||
expect(queryByTestId('agent-task-run')).not.toBeInTheDocument();
|
||||
|
||||
await rerender({
|
||||
|
||||
+80
-27
@@ -7,6 +7,7 @@ import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||
import { defineComponent, onMounted, nextTick } from 'vue';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
|
||||
import { AgentModalTestStub } from './utils/AgentModalTestStub';
|
||||
|
||||
import AgentToolConfigModal from '../components/AgentToolConfigModal.vue';
|
||||
import type { AgentJsonToolRef, CustomToolEntry } from '../types';
|
||||
@@ -138,6 +139,17 @@ function createWorkflowToolConfigStub(emitValid: boolean) {
|
||||
|
||||
const MODAL_NAME = 'AgentToolConfigModal';
|
||||
|
||||
function getNativeTestId(container: ParentNode, id: string): HTMLElement {
|
||||
const element = container.querySelector(`[data-testid="${id}"]`);
|
||||
if (!(element instanceof HTMLElement)) throw new Error(`Missing data-testid: ${id}`);
|
||||
return element;
|
||||
}
|
||||
|
||||
function queryNativeTestId(container: ParentNode, id: string): HTMLElement | null {
|
||||
const element = container.querySelector(`[data-testid="${id}"]`);
|
||||
return element instanceof HTMLElement ? element : null;
|
||||
}
|
||||
|
||||
function toolRef(
|
||||
overrides: Partial<Extract<AgentJsonToolRef, { type: 'node' }>['node']> = {},
|
||||
): Extract<AgentJsonToolRef, { type: 'node' }> {
|
||||
@@ -158,6 +170,7 @@ function toolRef(
|
||||
function renderModal({
|
||||
valid = false,
|
||||
onConfirm = vi.fn(),
|
||||
onRemove,
|
||||
ref = toolRef(),
|
||||
customTool,
|
||||
projectId,
|
||||
@@ -166,6 +179,7 @@ function renderModal({
|
||||
}: {
|
||||
valid?: boolean;
|
||||
onConfirm?: (updated: AgentJsonToolRef) => void;
|
||||
onRemove?: () => void;
|
||||
ref?: AgentJsonToolRef;
|
||||
customTool?: CustomToolEntry;
|
||||
projectId?: string;
|
||||
@@ -175,6 +189,7 @@ function renderModal({
|
||||
const renderComponent = createComponentRenderer(AgentToolConfigModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
AgentModal: AgentModalTestStub,
|
||||
NodeIcon: { template: '<div data-test-id="header-node-icon" />' },
|
||||
FocusScope: {
|
||||
template: '<div data-test-id="nested-credential-focus-scope"><slot /></div>',
|
||||
@@ -205,6 +220,7 @@ function renderModal({
|
||||
agentId,
|
||||
validationIssues,
|
||||
onConfirm,
|
||||
onRemove,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -226,20 +242,32 @@ describe('AgentToolConfigModal', () => {
|
||||
expect(getByTestId('node-tool-settings-content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not open for a persisted node tool without node data', () => {
|
||||
const invalidRef = {
|
||||
type: 'node',
|
||||
name: 'Unavailable tool',
|
||||
description: 'Node data is missing',
|
||||
} as AgentJsonToolRef;
|
||||
|
||||
const { queryByRole } = renderModal({ ref: invalidRef });
|
||||
|
||||
expect(queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('releases dialog focus handling while the credential modal is open', async () => {
|
||||
const { getByRole, getByTestId, queryByTestId } = renderModal();
|
||||
const { container, getByRole } = renderModal();
|
||||
const dialog = getByRole('dialog');
|
||||
|
||||
expect(dialog).toHaveAttribute('data-trap-focus', 'true');
|
||||
expect(dialog).toHaveAttribute('data-disable-outside-pointer-events', 'true');
|
||||
expect(queryByTestId('nested-credential-focus-scope')).toBeNull();
|
||||
expect(queryNativeTestId(container, 'nested-credential-focus-scope')).toBeNull();
|
||||
|
||||
uiStore.openModal(CREDENTIAL_EDIT_MODAL_KEY);
|
||||
await nextTick();
|
||||
|
||||
expect(dialog).toHaveAttribute('data-trap-focus', 'false');
|
||||
expect(dialog).toHaveAttribute('data-disable-outside-pointer-events', 'false');
|
||||
expect(getByTestId('nested-credential-focus-scope')).toBeInTheDocument();
|
||||
expect(getNativeTestId(container, 'nested-credential-focus-scope')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes agent project context to the node-tool settings content', () => {
|
||||
@@ -258,24 +286,30 @@ describe('AgentToolConfigModal', () => {
|
||||
expect(queryByTestId('agent-tool-config-permissions-tab')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables Save until the content emits valid=true', async () => {
|
||||
const { getByTestId } = renderModal({ valid: false });
|
||||
it('keeps Save available and does not confirm invalid content', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const { container } = renderModal({ valid: false, onConfirm });
|
||||
await nextTick();
|
||||
const saveBtn = getByTestId('agent-tool-config-save') as HTMLButtonElement;
|
||||
expect(saveBtn.disabled).toBe(true);
|
||||
const saveBtn = getNativeTestId(container, 'agent-tool-config-save') as HTMLButtonElement;
|
||||
expect(saveBtn.disabled).toBe(false);
|
||||
|
||||
await fireEvent.click(saveBtn);
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(getNativeTestId(container, 'agent-tool-config-validation-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables Save once valid and round-trips the node back into the toolRef on confirm', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const initial = toolRef();
|
||||
const { getByTestId } = renderModal({ valid: true, onConfirm, ref: initial });
|
||||
const { container } = renderModal({ valid: true, onConfirm, ref: initial });
|
||||
|
||||
await waitFor(() => {
|
||||
const saveBtn = getByTestId('agent-tool-config-save') as HTMLButtonElement;
|
||||
const saveBtn = getNativeTestId(container, 'agent-tool-config-save') as HTMLButtonElement;
|
||||
expect(saveBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('agent-tool-config-save'));
|
||||
await fireEvent.click(getNativeTestId(container, 'agent-tool-config-save'));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
const [updated] = onConfirm.mock.calls[0];
|
||||
@@ -289,7 +323,7 @@ describe('AgentToolConfigModal', () => {
|
||||
});
|
||||
|
||||
it('shows the HTTP Request URL error and blocks Save for a model override', async () => {
|
||||
const { getByTestId, queryByTestId } = renderModal({
|
||||
const { container, getByTestId, queryByTestId } = renderModal({
|
||||
valid: true,
|
||||
ref: toolRef({
|
||||
nodeType: 'n8n-nodes-base.httpRequestTool',
|
||||
@@ -311,15 +345,17 @@ describe('AgentToolConfigModal', () => {
|
||||
'agents.builder.validation.issue.httpRequestUrlFromAi',
|
||||
);
|
||||
expect(queryByTestId('from-ai-override-button')).not.toBeInTheDocument();
|
||||
expect(getByTestId('agent-tool-config-save')).toBeDisabled();
|
||||
expect(getNativeTestId(container, 'agent-tool-config-save')).not.toBeDisabled();
|
||||
await fireEvent.click(getNativeTestId(container, 'agent-tool-config-save'));
|
||||
expect(getNativeTestId(container, 'agent-tool-config-validation-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves the approval requirement on node tool refs', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const { getByTestId } = renderModal({ valid: true, onConfirm, ref: toolRef() });
|
||||
const { container, getByTestId } = renderModal({ valid: true, onConfirm, ref: toolRef() });
|
||||
|
||||
await fireEvent.click(getByTestId('agent-tool-approval-toggle'));
|
||||
await fireEvent.click(getByTestId('agent-tool-config-save'));
|
||||
await fireEvent.click(getNativeTestId(container, 'agent-tool-config-save'));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
const [updated] = onConfirm.mock.calls[0];
|
||||
@@ -338,14 +374,11 @@ describe('AgentToolConfigModal', () => {
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
|
||||
it('closes the modal on Cancel without calling onConfirm', async () => {
|
||||
it('closes the modal from the header without calling onConfirm', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const { getAllByRole } = renderModal({ valid: true, onConfirm });
|
||||
const { container } = renderModal({ valid: true, onConfirm });
|
||||
|
||||
const buttons = getAllByRole('button');
|
||||
const cancelBtn = buttons.find((b) => b.textContent?.includes('agents.toolConfig.cancel'));
|
||||
expect(cancelBtn).toBeTruthy();
|
||||
await fireEvent.click(cancelBtn!);
|
||||
await fireEvent.click(getNativeTestId(container, 'dialog-close-button'));
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME);
|
||||
@@ -368,14 +401,14 @@ describe('AgentToolConfigModal', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const { getByTestId, queryByTestId } = renderModal({
|
||||
const { container, getByTestId, queryByTestId } = renderModal({
|
||||
ref: { type: 'custom', id: 'custom-tool-1' },
|
||||
customTool,
|
||||
});
|
||||
expect(getByTestId('agent-custom-tool-viewer').textContent).toContain(customTool.code);
|
||||
expect(queryByTestId('node-tool-settings-content')).toBeNull();
|
||||
expect(queryByTestId('workflow-tool-config-content')).toBeNull();
|
||||
expect(queryByTestId('agent-tool-config-save')).not.toBeNull();
|
||||
expect(queryNativeTestId(container, 'agent-tool-config-save')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('saves the approval requirement on custom tool refs', async () => {
|
||||
@@ -395,7 +428,7 @@ describe('AgentToolConfigModal', () => {
|
||||
providerOptions: null,
|
||||
},
|
||||
};
|
||||
const { getByTestId } = renderModal({
|
||||
const { container, getByTestId } = renderModal({
|
||||
valid: true,
|
||||
onConfirm,
|
||||
ref: { type: 'custom', id: 'custom-tool-1' },
|
||||
@@ -403,7 +436,7 @@ describe('AgentToolConfigModal', () => {
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('agent-tool-approval-toggle'));
|
||||
await fireEvent.click(getByTestId('agent-tool-config-save'));
|
||||
await fireEvent.click(getNativeTestId(container, 'agent-tool-config-save'));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
const [updated] = onConfirm.mock.calls[0];
|
||||
@@ -412,7 +445,7 @@ describe('AgentToolConfigModal', () => {
|
||||
|
||||
it('preserves the stable workflow id when saving a workflow tool', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const { getByTestId, queryByTestId } = renderModal({
|
||||
const { container, getByTestId, queryByTestId } = renderModal({
|
||||
valid: true,
|
||||
onConfirm,
|
||||
ref: {
|
||||
@@ -428,9 +461,9 @@ describe('AgentToolConfigModal', () => {
|
||||
expect(queryByTestId('node-tool-settings-content')).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('agent-tool-config-save')).not.toBeDisabled();
|
||||
expect(getNativeTestId(container, 'agent-tool-config-save')).not.toBeDisabled();
|
||||
});
|
||||
await fireEvent.click(getByTestId('agent-tool-config-save'));
|
||||
await fireEvent.click(getNativeTestId(container, 'agent-tool-config-save'));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -440,4 +473,24 @@ describe('AgentToolConfigModal', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses an explicit Remove workflow action', async () => {
|
||||
const onRemove = vi.fn();
|
||||
const { container, getByText } = renderModal({
|
||||
onRemove,
|
||||
ref: {
|
||||
type: 'workflow',
|
||||
workflowId: 'wf-1',
|
||||
workflow: 'My Workflow',
|
||||
name: 'My Workflow Tool',
|
||||
description: 'Does something',
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('agents.builder.tools.workflow.remove')).toBeInTheDocument();
|
||||
await fireEvent.click(getNativeTestId(container, 'agent-tool-config-remove'));
|
||||
|
||||
expect(onRemove).toHaveBeenCalledOnce();
|
||||
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
+237
-77
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent } from 'vue';
|
||||
import { defineComponent, watchEffect } from 'vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
|
||||
@@ -24,6 +24,7 @@ import type { IWorkflowDb } from '@/Interface';
|
||||
|
||||
import type { ToolPickerMode } from '../components/AgentCapabilitiesSection.types';
|
||||
import AgentToolsConnectionModalWrapper from '../components/AgentToolsConnectionModalWrapper.vue';
|
||||
import type { AgentToolConfigModalData } from '../components/AgentToolConfigForm.vue';
|
||||
import type { AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types';
|
||||
|
||||
const showMessageMock = vi.fn();
|
||||
@@ -128,12 +129,80 @@ const MCP_TOOL: INodeTypeDescription = {
|
||||
};
|
||||
|
||||
let modalAttrs: Record<string, unknown> = {};
|
||||
let multiStepAttrs: Record<string, unknown> = {};
|
||||
let configFormData: AgentToolConfigModalData | null = null;
|
||||
let configuredResult: AgentJsonToolRef | AgentJsonMcpServerConfig | null = null;
|
||||
|
||||
const AgentModalMultiStepStub = defineComponent({
|
||||
name: 'AgentModalMultiStep',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
open: Boolean,
|
||||
step: String,
|
||||
title: String,
|
||||
showBack: Boolean,
|
||||
showFooter: Boolean,
|
||||
},
|
||||
emits: ['update:open', 'update:title', 'back', 'interactOutside'],
|
||||
setup(props, { attrs }) {
|
||||
watchEffect(() => {
|
||||
multiStepAttrs = { ...attrs, ...props };
|
||||
});
|
||||
return {};
|
||||
},
|
||||
template: `
|
||||
<section v-if="open" data-test-id="agent-modal-multi-step" :data-step="step">
|
||||
<header>
|
||||
<button v-if="showBack" data-test-id="agent-modal-back" @click="$emit('back')" />
|
||||
<span>{{ title }}</span>
|
||||
</header>
|
||||
<slot />
|
||||
<footer v-if="showFooter">
|
||||
<slot name="footerLeft" />
|
||||
<slot name="footerActions" />
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
});
|
||||
|
||||
const AgentToolConfigFormStub = defineComponent({
|
||||
name: 'AgentToolConfigForm',
|
||||
props: ['data'],
|
||||
setup(props, { expose }) {
|
||||
watchEffect(() => {
|
||||
configFormData = props.data as AgentToolConfigModalData;
|
||||
});
|
||||
expose({
|
||||
confirm: () => {
|
||||
const data = props.data as AgentToolConfigModalData;
|
||||
const result =
|
||||
configuredResult ?? (data.kind === 'mcpServer' ? data.mcpServer : data.toolRef);
|
||||
if (data.kind === 'mcpServer') {
|
||||
data.onConfirm(result as AgentJsonMcpServerConfig);
|
||||
} else {
|
||||
data.onConfirm(result as AgentJsonToolRef);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
remove: () => (props.data as AgentToolConfigModalData).onRemove?.(),
|
||||
changeTitle: vi.fn(),
|
||||
});
|
||||
return {};
|
||||
},
|
||||
template: '<div data-test-id="agent-tool-config-form-stub" />',
|
||||
});
|
||||
|
||||
const ToolsConnectionModalStub = defineComponent({
|
||||
name: 'ToolsConnectionModal',
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs }) {
|
||||
modalAttrs = attrs;
|
||||
props: {
|
||||
persistentScrollbar: Boolean,
|
||||
},
|
||||
setup(props, { attrs }) {
|
||||
watchEffect(() => {
|
||||
modalAttrs = { ...attrs, ...props };
|
||||
});
|
||||
return {};
|
||||
},
|
||||
template:
|
||||
@@ -169,8 +238,8 @@ function emitSearch(query: string) {
|
||||
}
|
||||
|
||||
function emitCreateWorkflow() {
|
||||
const listener = modalAttrs.onCreateWorkflow;
|
||||
if (typeof listener !== 'function') throw new Error('Missing onCreateWorkflow');
|
||||
const listener = modalAttrs.onCreate;
|
||||
if (typeof listener !== 'function') throw new Error('Missing onCreate');
|
||||
(listener as () => void)();
|
||||
}
|
||||
|
||||
@@ -180,6 +249,8 @@ const PROJECT_ID = 'project-1';
|
||||
const renderComponent = createComponentRenderer(AgentToolsConnectionModalWrapper, {
|
||||
global: {
|
||||
stubs: {
|
||||
AgentModalMultiStep: AgentModalMultiStepStub,
|
||||
AgentToolConfigForm: AgentToolConfigFormStub,
|
||||
ToolsConnectionModal: ToolsConnectionModalStub,
|
||||
McpRegistrySuggestionFooter: McpRegistrySuggestionFooterStub,
|
||||
},
|
||||
@@ -199,6 +270,9 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
vi.clearAllMocks();
|
||||
uuidMockState.counter = 0;
|
||||
modalAttrs = {};
|
||||
multiStepAttrs = {};
|
||||
configFormData = null;
|
||||
configuredResult = null;
|
||||
createTestingPinia({ stubActions: false });
|
||||
|
||||
nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
@@ -278,18 +352,41 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
});
|
||||
}
|
||||
|
||||
function getConfigData(): AgentToolConfigModalData {
|
||||
if (!configFormData) throw new Error('The configure step is not open');
|
||||
return configFormData;
|
||||
}
|
||||
|
||||
async function saveConfiguration(
|
||||
result?: AgentJsonToolRef | AgentJsonMcpServerConfig,
|
||||
): Promise<void> {
|
||||
configuredResult = result ?? null;
|
||||
const button = document.querySelector('[data-testid="agent-tool-config-save"]');
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error('Missing Save button');
|
||||
button.click();
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
async function removeConfiguration(): Promise<void> {
|
||||
const button = document.querySelector('[data-testid="agent-tool-config-remove"]');
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error('Missing Remove button');
|
||||
button.click();
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
it('configures the suggestion footer copy', () => {
|
||||
const { getByText } = render();
|
||||
|
||||
expect(getByText('Need another capability?')).toBeInTheDocument();
|
||||
expect(getByText('Suggest a tool')).toBeInTheDocument();
|
||||
expect(modalAttrs.persistentScrollbar ?? modalAttrs['persistent-scrollbar']).toBe(true);
|
||||
});
|
||||
|
||||
// DynamicModalLoader passes `open`/`active`/`mode`/`activeId` on top of the
|
||||
// declared props. If those fall through onto ToolsConnectionModal the
|
||||
// inherited `open` is always true while mounted and would pin the dialog
|
||||
// open, so mount the way the loader does and drive it from the store.
|
||||
it('drives the dialog from the store, not the inherited loader attrs', async () => {
|
||||
it('does not forward inherited loader attributes to the picker', async () => {
|
||||
renderComponent({
|
||||
props: {
|
||||
modalName: MODAL_NAME,
|
||||
@@ -299,26 +396,30 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
});
|
||||
await flushPromises();
|
||||
expect(modalAttrs.open).toBe(true);
|
||||
|
||||
uiStore.modalStateById[MODAL_NAME].open = false;
|
||||
await flushPromises();
|
||||
expect(modalAttrs.open).toBe(false);
|
||||
expect(modalAttrs.active).toBeUndefined();
|
||||
expect(modalAttrs.mode).toBeUndefined();
|
||||
});
|
||||
|
||||
// The two dialogs are sequential, not stacked: this one steps aside for the
|
||||
// config modal, then comes back when it closes, so cancelling returns to the
|
||||
// list rather than dead-ending.
|
||||
it('steps aside while the tool config modal is up, then returns', async () => {
|
||||
it('opens configuration as the next step and returns with Back', async () => {
|
||||
render();
|
||||
await flushPromises();
|
||||
expect(modalAttrs.open).toBe(true);
|
||||
|
||||
uiStore.modalStateById.agentToolConfigModal.open = true;
|
||||
const slack = getItems().find((item) => item.id === `nodeType:${SLACK.name}`);
|
||||
emitConnect(slack!);
|
||||
await flushPromises();
|
||||
expect(modalAttrs.open).toBe(false);
|
||||
|
||||
uiStore.modalStateById.agentToolConfigModal.open = false;
|
||||
expect(multiStepAttrs.step).toBe('configure');
|
||||
expect(getConfigData()).toMatchObject({
|
||||
toolRef: { type: 'node', node: { nodeType: SLACK.name } },
|
||||
});
|
||||
|
||||
const back = document.querySelector('[data-test-id="agent-modal-back"]');
|
||||
if (!(back instanceof HTMLButtonElement)) throw new Error('Missing Back button');
|
||||
back.click();
|
||||
await flushPromises();
|
||||
|
||||
expect(multiStepAttrs.step).toBe('select');
|
||||
expect(modalAttrs.open).toBe(true);
|
||||
});
|
||||
|
||||
@@ -366,9 +467,14 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
expect(workflow?.category).toBe('workflows');
|
||||
expect(getItems().every((item) => item.category === 'workflows')).toBe(true);
|
||||
expect(modalAttrs.categories).toEqual(['workflows']);
|
||||
expect(modalAttrs.title).toBe('Workflows');
|
||||
expect(modalAttrs.searchPlaceholder).toBe('Search workflows');
|
||||
expect(modalAttrs.allowWorkflowCreation).toBe(true);
|
||||
expect(modalAttrs.title).toBe('Add workflow');
|
||||
expect(modalAttrs.searchPlaceholder ?? modalAttrs['search-placeholder']).toBe(
|
||||
'Search workflows',
|
||||
);
|
||||
expect(modalAttrs.createAction ?? modalAttrs['create-action']).toMatchObject({
|
||||
category: 'workflows',
|
||||
label: 'Create workflow',
|
||||
});
|
||||
});
|
||||
|
||||
it('installs an uninstalled community tool before adding it, and adds the installed type', async () => {
|
||||
@@ -407,6 +513,11 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getConfigData()).toMatchObject({
|
||||
toolRef: { type: 'node', node: { nodeType: COMMUNITY_INSTALLED.name } },
|
||||
});
|
||||
await saveConfiguration();
|
||||
|
||||
// The tool that gets added is the installed type, not the preview.
|
||||
const [{ tools }] = onConfirm.mock.calls[0];
|
||||
expect(tools[0].node.nodeType).toBe(COMMUNITY_INSTALLED.name);
|
||||
@@ -480,7 +591,35 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
expect(availableSlack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opens the config modal when Connect is clicked on a node that needs setup', async () => {
|
||||
it('excludes workflow refs from tool mode and preserves them when a node tool is added', async () => {
|
||||
const workflowRef: AgentJsonToolRef = {
|
||||
type: 'workflow',
|
||||
workflowId: 'workflow-1',
|
||||
workflow: 'Daily sales digest',
|
||||
name: 'Daily sales digest',
|
||||
description: 'Build the daily sales digest',
|
||||
allOutputs: false,
|
||||
};
|
||||
const onConfirm = vi.fn();
|
||||
render([workflowRef], onConfirm);
|
||||
await flushPromises();
|
||||
|
||||
expect(getItems().some((item) => item.kind === 'workflow')).toBe(false);
|
||||
|
||||
const slack = getItems().find((item) => item.id === `nodeType:${SLACK.name}`);
|
||||
emitConnect(slack!);
|
||||
await flushPromises();
|
||||
|
||||
const configuredRef = toolRef(SLACK.name);
|
||||
await saveConfiguration(configuredRef);
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
tools: [workflowRef, configuredRef],
|
||||
mcpServers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the inline configure step when Add is clicked', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render([], onConfirm);
|
||||
await flushPromises();
|
||||
@@ -488,18 +627,17 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
const slack = getItems().find((item) => item.id === `nodeType:${SLACK.name}`);
|
||||
expect(slack).toBeDefined();
|
||||
emitConnect(slack!);
|
||||
await flushPromises();
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(uiStore.openModalWithData).toHaveBeenCalledTimes(1);
|
||||
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(payload.name).toBe('agentToolConfigModal');
|
||||
expect(payload.data.toolRef).toMatchObject({
|
||||
type: 'node',
|
||||
node: { nodeType: SLACK.name },
|
||||
expect(uiStore.openModalWithData).not.toHaveBeenCalled();
|
||||
expect(multiStepAttrs.step).toBe('configure');
|
||||
expect(getConfigData()).toMatchObject({
|
||||
toolRef: { type: 'node', node: { nodeType: SLACK.name } },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds setup-less tools directly and commits them via onConfirm', async () => {
|
||||
it('uses the configure step for setup-less tools and commits on Save', async () => {
|
||||
nodeTypesStore.visibleNodeTypesByOutputConnectionTypeNames = {
|
||||
[NodeConnectionTypes.AiTool]: [WIKIPEDIA.name],
|
||||
};
|
||||
@@ -510,8 +648,15 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
const wikipedia = getItems().find((item) => item.id === `nodeType:${WIKIPEDIA.name}`);
|
||||
expect(wikipedia).toBeDefined();
|
||||
emitConnect(wikipedia!);
|
||||
await flushPromises();
|
||||
|
||||
expect(uiStore.openModalWithData).not.toHaveBeenCalled();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(getConfigData()).toMatchObject({
|
||||
toolRef: { type: 'node', name: 'Wikipedia' },
|
||||
});
|
||||
await saveConfiguration();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
const [{ tools }] = onConfirm.mock.calls[0];
|
||||
expect(tools).toEqual([
|
||||
@@ -537,15 +682,17 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
const connected = getItems().find((item) => item.status === 'connected');
|
||||
emitConnect(connected!);
|
||||
|
||||
// Activating a connected node-tool row opens the config modal for a NEW
|
||||
// Activating a connected node-tool row opens configuration for a new
|
||||
// instance (fresh ref, empty parameters) — not an edit of the existing one.
|
||||
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(payload.name).toBe('agentToolConfigModal');
|
||||
expect(payload.data.toolRef).toMatchObject({
|
||||
type: 'node',
|
||||
node: { nodeType: SLACK.name, nodeParameters: {} },
|
||||
await flushPromises();
|
||||
const data = getConfigData();
|
||||
expect(data).toMatchObject({
|
||||
toolRef: {
|
||||
type: 'node',
|
||||
node: { nodeType: SLACK.name, nodeParameters: {} },
|
||||
},
|
||||
});
|
||||
expect(payload.data.existingToolNames).toContain(existing.name);
|
||||
expect(data.existingToolNames).toContain(existing.name);
|
||||
|
||||
const configuredRef: AgentJsonToolRef = {
|
||||
type: 'node',
|
||||
@@ -557,7 +704,7 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
credentials: { slackApi: { id: 'c-2', name: 'Other Slack' } },
|
||||
},
|
||||
};
|
||||
payload.data.onConfirm(configuredRef);
|
||||
await saveConfiguration(configuredRef);
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
tools: [existing, configuredRef],
|
||||
@@ -576,21 +723,21 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
emitConnect(connected!);
|
||||
|
||||
// The connected row now opens config for a new instance; there is no
|
||||
// remove callback on that flow (removal happens via the capabilities chips).
|
||||
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(payload.data.onRemove).toBeUndefined();
|
||||
// Remove callback on that flow. Removal happens through the capability pills.
|
||||
await flushPromises();
|
||||
expect(getConfigData().onRemove).toBeUndefined();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('appends a configured tool once the config modal saves', async () => {
|
||||
it('appends a configured tool once the configure step saves', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render([], onConfirm);
|
||||
await flushPromises();
|
||||
|
||||
const slack = getItems().find((item) => item.id === `nodeType:${SLACK.name}`);
|
||||
emitConnect(slack!);
|
||||
await flushPromises();
|
||||
|
||||
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const configuredRef: AgentJsonToolRef = {
|
||||
type: 'node',
|
||||
name: 'Slack',
|
||||
@@ -601,7 +748,7 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
credentials: { slackApi: { id: 'c-1', name: 'Prod Slack' } },
|
||||
},
|
||||
};
|
||||
payload.data.onConfirm(configuredRef);
|
||||
await saveConfiguration(configuredRef);
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
tools: [configuredRef],
|
||||
@@ -624,6 +771,12 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
await flushPromises();
|
||||
|
||||
emitConnect(getItems().find((item) => item.id === `nodeType:${WIKIPEDIA.name}`)!);
|
||||
await flushPromises();
|
||||
|
||||
expect(getConfigData()).toMatchObject({
|
||||
toolRef: { type: 'node', name: 'Wikipedia (1)' },
|
||||
});
|
||||
await saveConfiguration();
|
||||
|
||||
const [{ tools }] = onConfirm.mock.calls[0];
|
||||
expect(tools.map((tool: Extract<AgentJsonToolRef, { type: 'node' }>) => tool.name)).toEqual([
|
||||
@@ -727,27 +880,27 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(uiStore.closeModal).not.toHaveBeenCalled();
|
||||
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(payload).toMatchObject({
|
||||
name: 'agentToolConfigModal',
|
||||
data: {
|
||||
projectId: PROJECT_ID,
|
||||
toolRef: {
|
||||
type: 'workflow',
|
||||
workflowId: 'new-workflow-id',
|
||||
workflow: 'My workflow 1',
|
||||
name: 'My workflow 1',
|
||||
description: '',
|
||||
allOutputs: false,
|
||||
},
|
||||
const data = getConfigData();
|
||||
expect(data).toMatchObject({
|
||||
projectId: PROJECT_ID,
|
||||
toolRef: {
|
||||
type: 'workflow',
|
||||
workflowId: 'new-workflow-id',
|
||||
workflow: 'My workflow 1',
|
||||
name: 'My workflow 1',
|
||||
description: '',
|
||||
allOutputs: false,
|
||||
},
|
||||
});
|
||||
if (data.kind === 'mcpServer' || data.toolRef.type !== 'workflow') {
|
||||
throw new Error('Expected a workflow tool');
|
||||
}
|
||||
|
||||
const configuredRef: AgentJsonToolRef = {
|
||||
...payload.data.toolRef,
|
||||
...data.toolRef,
|
||||
description: 'Create the daily sales digest',
|
||||
};
|
||||
payload.data.onConfirm(configuredRef);
|
||||
await saveConfiguration(configuredRef);
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
tools: [existingTool, configuredRef],
|
||||
@@ -882,24 +1035,24 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
};
|
||||
});
|
||||
|
||||
it('commits an added MCP server to the host once its config modal saves', async () => {
|
||||
it('commits an added MCP server to the host once its configure step saves', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render([], onConfirm);
|
||||
await flushPromises();
|
||||
|
||||
emitConnect(getItems().find((item) => item.id === `nodeType:${MCP_TOOL.name}`)!);
|
||||
await flushPromises();
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(payload.data.kind).toBe('mcpServer');
|
||||
expect(getConfigData().kind).toBe('mcpServer');
|
||||
|
||||
payload.data.onConfirm(SERVER);
|
||||
await saveConfiguration(SERVER);
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({ tools: [], mcpServers: [SERVER] });
|
||||
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME);
|
||||
});
|
||||
|
||||
it('removes a connected MCP server when its config modal asks to', async () => {
|
||||
it('removes a connected MCP server from its configure step', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render([], onConfirm, [SERVER]);
|
||||
await flushPromises();
|
||||
@@ -908,8 +1061,9 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
expect(connected?.title).toBe(SERVER.name);
|
||||
|
||||
emitConnect(connected!);
|
||||
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
payload.data.onRemove();
|
||||
await flushPromises();
|
||||
expect(getConfigData().onRemove).toBeTypeOf('function');
|
||||
await removeConfiguration();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({ tools: [], mcpServers: [] });
|
||||
});
|
||||
@@ -964,36 +1118,39 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
expect(modalAttrs.categories as string[]).not.toContain('n8n-connect');
|
||||
});
|
||||
|
||||
it('opens the config modal with the managed credential pre-selected', async () => {
|
||||
it('opens the configure step with the managed credential pre-selected', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render([], onConfirm);
|
||||
await flushPromises();
|
||||
|
||||
const gateway = getItems().find((item) => item.id === `n8n-connect:${SLACK.name}`);
|
||||
emitConnect(gateway!);
|
||||
await flushPromises();
|
||||
|
||||
// Behaves like any other node tool — the config modal opens so the user
|
||||
// Behaves like any other node tool. The configure step opens so the user
|
||||
// can pick the operation — only the credential is handled for them.
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(uiStore.openModalWithData).toHaveBeenCalledTimes(1);
|
||||
expect(uiStore.openModalWithData).not.toHaveBeenCalled();
|
||||
|
||||
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(payload.name).toBe('agentToolConfigModal');
|
||||
expect(payload.data.toolRef.node.credentials).toEqual({
|
||||
const data = getConfigData();
|
||||
if (data.kind === 'mcpServer' || data.toolRef.type !== 'node') {
|
||||
throw new Error('Expected a node tool');
|
||||
}
|
||||
expect(data.toolRef.node.credentials).toEqual({
|
||||
slackApi: { id: null, name: '', __aiGatewayManaged: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('commits the gateway tool once its config modal saves', async () => {
|
||||
it('commits the gateway tool once its configure step saves', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render([], onConfirm);
|
||||
await flushPromises();
|
||||
|
||||
const gateway = getItems().find((item) => item.id === `n8n-connect:${SLACK.name}`);
|
||||
emitConnect(gateway!);
|
||||
await flushPromises();
|
||||
|
||||
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
payload.data.onConfirm(payload.data.toolRef);
|
||||
await saveConfiguration();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
const [{ tools }] = onConfirm.mock.calls[0];
|
||||
@@ -1020,15 +1177,18 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
||||
|
||||
const connected = getItems().find((item) => item.status === 'connected');
|
||||
emitConnect(connected!);
|
||||
await flushPromises();
|
||||
|
||||
// Activating a connected managed tool routes through the managed add
|
||||
// path, so the new instance keeps the __aiGatewayManaged credential.
|
||||
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(payload.name).toBe('agentToolConfigModal');
|
||||
expect(payload.data.toolRef.node.credentials).toEqual({
|
||||
const data = getConfigData();
|
||||
if (data.kind === 'mcpServer' || data.toolRef.type !== 'node') {
|
||||
throw new Error('Expected a node tool');
|
||||
}
|
||||
expect(data.toolRef.node.credentials).toEqual({
|
||||
slackApi: { id: null, name: '', __aiGatewayManaged: true },
|
||||
});
|
||||
expect(payload.data.existingToolNames).toContain(existing.name);
|
||||
expect(data.existingToolNames).toContain(existing.name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+25
-23
@@ -15,7 +15,11 @@ const showErrorMock = vi.fn();
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
baseText: (key: string, options?: { interpolate?: Record<string, string> }) =>
|
||||
options?.interpolate ? `${key}:${Object.values(options.interpolate).join(',')}` : key,
|
||||
key === 'agents.builder.vectorStores.modal.defaultName'
|
||||
? 'new_vector_store'
|
||||
: options?.interpolate
|
||||
? `${key}:${Object.values(options.interpolate).join(',')}`
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -36,6 +40,7 @@ vi.mock('@/app/stores/ui.store', () => ({
|
||||
closeModal: closeModalMock,
|
||||
openNewCredential: openNewCredentialMock,
|
||||
isModalActiveById: {},
|
||||
modalsById: { agentVectorStoresModal: { open: true } },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -58,13 +63,8 @@ vi.mock('../composables/useAgentApi', () => ({
|
||||
testAgentVectorStore: (...args: unknown[]) => testAgentVectorStoreMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/components/Modal.vue', () => ({
|
||||
default: {
|
||||
name: 'Modal',
|
||||
props: ['name', 'width', 'customClass'],
|
||||
template:
|
||||
'<section><header><slot name="header" /></header><main><slot name="content" /></main><footer><slot name="footer" /></footer></section>',
|
||||
},
|
||||
vi.mock('../components/modals/AgentModalMultiStep.vue', async () => ({
|
||||
default: (await import('./utils/AgentModalTestStub')).AgentModalMultiStepTestStub,
|
||||
}));
|
||||
|
||||
vi.mock('@/features/credentials/components/CredentialIcon.vue', () => ({
|
||||
@@ -131,9 +131,7 @@ async function selectQdrantProvider(wrapper: ReturnType<typeof mount>) {
|
||||
}
|
||||
|
||||
async function fillQdrantConnection(wrapper: ReturnType<typeof mount>) {
|
||||
await wrapper
|
||||
.find('[data-testid="agent-vector-stores-modal-name"] input')
|
||||
.setValue('product_docs');
|
||||
await wrapper.find('[data-testid="agent-modal-title-input"]').setValue('product_docs');
|
||||
await wrapper
|
||||
.find('[data-testid="agent-vector-stores-modal-collection-name"] input')
|
||||
.setValue('docs');
|
||||
@@ -205,7 +203,7 @@ describe('AgentVectorStoresModal', () => {
|
||||
expect(useWhenBlock.text()).toMatch(/required/i);
|
||||
});
|
||||
|
||||
it('disables Connect until required fields are filled, then tests and confirms', async () => {
|
||||
it('reveals validation until required fields are filled, then tests and confirms', async () => {
|
||||
testAgentVectorStoreMock.mockResolvedValue({ success: true });
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
@@ -219,7 +217,12 @@ describe('AgentVectorStoresModal', () => {
|
||||
|
||||
await selectQdrantProvider(wrapper);
|
||||
const confirmButton = wrapper.find('[data-testid="agent-vector-stores-modal-confirm"]');
|
||||
expect(confirmButton.attributes('disabled')).toBeDefined();
|
||||
expect(confirmButton.attributes('disabled')).toBeUndefined();
|
||||
await confirmButton.trigger('click');
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(
|
||||
wrapper.get('[data-testid="agent-vector-stores-modal-credential-required"]').text(),
|
||||
).toBe('agents.builder.vectorStores.modal.credential.required');
|
||||
|
||||
await fillQdrantConnection(wrapper);
|
||||
expect(confirmButton.attributes('disabled')).toBeUndefined();
|
||||
@@ -230,9 +233,7 @@ describe('AgentVectorStoresModal', () => {
|
||||
expect(testAgentVectorStoreMock).toHaveBeenCalledWith({}, 'p1', expectedVectorStore);
|
||||
expect(onConfirm).toHaveBeenCalledWith(expectedVectorStore);
|
||||
expect(closeModalMock).toHaveBeenCalledWith('agentVectorStoresModal');
|
||||
expect(showMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'success', title: expect.stringContaining('product_docs') }),
|
||||
);
|
||||
expect(showMessageMock).not.toHaveBeenCalled();
|
||||
expect(showErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -348,7 +349,10 @@ describe('AgentVectorStoresModal', () => {
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="agent-vector-stores-modal-name"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="agent-modal-title-input"]').element).toHaveProperty(
|
||||
'value',
|
||||
'product_docs',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears provider-specific fields when going back to the provider picker', async () => {
|
||||
@@ -377,21 +381,19 @@ describe('AgentVectorStoresModal', () => {
|
||||
.setValue('pinecone-cred-1');
|
||||
|
||||
// Back to the picker, then pick Qdrant instead.
|
||||
await wrapper.find('[data-testid="agent-vector-stores-modal-back"]').trigger('click');
|
||||
await wrapper.find('[data-testid="agent-modal-back"]').trigger('click');
|
||||
await wrapper.findAll('[data-testid="agent-vector-stores-modal-connect"]')[2].trigger('click');
|
||||
|
||||
// Pinecone leftovers must be gone.
|
||||
const nameInput = wrapper.find<HTMLInputElement>(
|
||||
'[data-testid="agent-vector-stores-modal-name"] input',
|
||||
);
|
||||
expect(nameInput.element.value).toBe('');
|
||||
const nameInput = wrapper.find<HTMLInputElement>('[data-testid="agent-modal-title-input"]');
|
||||
expect(nameInput.element.value).toBe('new_vector_store');
|
||||
const credentialSelect = wrapper.find<HTMLSelectElement>(
|
||||
'[data-test-id="agent-vector-stores-modal-credential"]',
|
||||
);
|
||||
expect(credentialSelect.element.value).toBe('');
|
||||
|
||||
// Returning to Pinecone must not resurrect the old index name.
|
||||
await wrapper.find('[data-testid="agent-vector-stores-modal-back"]').trigger('click');
|
||||
await wrapper.find('[data-testid="agent-modal-back"]').trigger('click');
|
||||
await wrapper.findAll('[data-testid="agent-vector-stores-modal-connect"]')[0].trigger('click');
|
||||
const indexInput = wrapper.find<HTMLInputElement>(
|
||||
'[data-testid="agent-vector-stores-modal-index-name"] input',
|
||||
|
||||
+150
-5
@@ -24,9 +24,12 @@ vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: () => ({ restApiContext: { baseUrl: 'http://localhost:5678' } }),
|
||||
}));
|
||||
|
||||
const { showMessageSpy } = vi.hoisted(() => ({ showMessageSpy: vi.fn() }));
|
||||
const { showErrorSpy, showMessageSpy } = vi.hoisted(() => ({
|
||||
showErrorSpy: vi.fn(),
|
||||
showMessageSpy: vi.fn(),
|
||||
}));
|
||||
vi.mock('@n8n/composables/useToast', () => ({
|
||||
useToast: () => ({ showError: vi.fn(), showMessage: showMessageSpy }),
|
||||
useToast: () => ({ showError: showErrorSpy, showMessage: showMessageSpy }),
|
||||
}));
|
||||
|
||||
vi.mock('../composables/useAgentApi', () => ({
|
||||
@@ -48,7 +51,11 @@ function setup(
|
||||
overrides: {
|
||||
supportsToolApproval?: boolean;
|
||||
ensureAgentPersisted?: () => Promise<void>;
|
||||
beforeAgentMutation?: () => Promise<void>;
|
||||
refreshAgentAfterMutation?: (projectId: string, agentId: string) => Promise<boolean>;
|
||||
agent?: Ref<AgentResource | null>;
|
||||
agentId?: Ref<string>;
|
||||
projectId?: Ref<string>;
|
||||
} = {},
|
||||
) {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }));
|
||||
@@ -56,19 +63,21 @@ function setup(
|
||||
|
||||
const localConfig = ref<AgentJsonConfig | null>(makeConfig());
|
||||
const scheduleConfigUpdate = vi.fn();
|
||||
const agentId = overrides.agentId ?? ref('inline:node-1');
|
||||
const projectId = overrides.projectId ?? ref('project-1');
|
||||
|
||||
const actions = useAgentCapabilitiesActions({
|
||||
localConfig,
|
||||
agent: ref<AgentResource | null>(null),
|
||||
projectId: computed(() => 'project-1'),
|
||||
agentId: computed(() => 'inline:node-1'),
|
||||
projectId: computed(() => projectId.value),
|
||||
agentId: computed(() => agentId.value),
|
||||
connectedTriggers: ref([]),
|
||||
scheduleConfigUpdate,
|
||||
scheduleSkillSave: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
return { uiStore, actions, scheduleConfigUpdate, localConfig };
|
||||
return { uiStore, actions, scheduleConfigUpdate, localConfig, agentId, projectId };
|
||||
}
|
||||
|
||||
type ToolsModalData = {
|
||||
@@ -240,6 +249,142 @@ describe('useAgentCapabilitiesActions — localSkills host seam', () => {
|
||||
expect(agent.value?.skillHashes).toEqual({ skill_new: 'hash-new' });
|
||||
});
|
||||
|
||||
it('flushes pending edits and refreshes the config before applying a created skill ref', async () => {
|
||||
const agent = ref<AgentResource | null>({
|
||||
id: 'inline:node-1',
|
||||
skills: {},
|
||||
skillHashes: {},
|
||||
} as unknown as AgentResource);
|
||||
const beforeAgentMutation = vi.fn().mockResolvedValue(undefined);
|
||||
const refreshAgentAfterMutation = vi.fn().mockResolvedValue(true);
|
||||
vi.mocked(createAgentSkill).mockResolvedValue({
|
||||
id: 'skill_new',
|
||||
skill: { ...triage, name: 'New Skill' },
|
||||
skillHash: 'hash-new',
|
||||
versionId: 'v2',
|
||||
});
|
||||
const { uiStore, actions, localConfig, scheduleConfigUpdate } = setup({
|
||||
agent,
|
||||
beforeAgentMutation,
|
||||
refreshAgentAfterMutation,
|
||||
});
|
||||
|
||||
actions.onOpenAddSkillModal();
|
||||
const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
|
||||
modalData.onConfirm({ skill: { ...triage, name: 'New Skill' } });
|
||||
await flushPromises();
|
||||
|
||||
expect(beforeAgentMutation).toHaveBeenCalledOnce();
|
||||
expect(beforeAgentMutation.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(createAgentSkill).mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(refreshAgentAfterMutation).toHaveBeenCalledWith('project-1', 'inline:node-1');
|
||||
expect(localConfig.value?.skills).toEqual([{ type: 'skill', id: 'skill_new' }]);
|
||||
expect(scheduleConfigUpdate).not.toHaveBeenCalled();
|
||||
expect(showMessageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not apply a created skill ref after the active agent changes during refresh', async () => {
|
||||
let releaseRefresh: (refreshed: boolean) => void = () => {};
|
||||
const refresh = new Promise<boolean>((resolve) => {
|
||||
releaseRefresh = resolve;
|
||||
});
|
||||
const agentId = ref('inline:node-1');
|
||||
const agent = ref<AgentResource | null>({
|
||||
id: 'inline:node-1',
|
||||
skills: {},
|
||||
skillHashes: {},
|
||||
} as unknown as AgentResource);
|
||||
vi.mocked(createAgentSkill).mockResolvedValue({
|
||||
id: 'skill_new',
|
||||
skill: { ...triage, name: 'New Skill' },
|
||||
skillHash: 'hash-new',
|
||||
versionId: 'v2',
|
||||
});
|
||||
const { uiStore, actions, localConfig } = setup({
|
||||
agent,
|
||||
agentId,
|
||||
refreshAgentAfterMutation: async () => await refresh,
|
||||
});
|
||||
|
||||
actions.onOpenAddSkillModal();
|
||||
const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
|
||||
modalData.onConfirm({ skill: { ...triage, name: 'New Skill' } });
|
||||
await flushPromises();
|
||||
agentId.value = 'inline:node-2';
|
||||
agent.value = { id: 'inline:node-2', skills: {} } as unknown as AgentResource;
|
||||
localConfig.value = makeConfig();
|
||||
releaseRefresh(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(localConfig.value.skills).toBeUndefined();
|
||||
expect(agent.value.skills).toEqual({});
|
||||
});
|
||||
|
||||
it('does not apply a created skill ref after the active project changes during refresh', async () => {
|
||||
let releaseRefresh: (refreshed: boolean) => void = () => {};
|
||||
const refresh = new Promise<boolean>((resolve) => {
|
||||
releaseRefresh = resolve;
|
||||
});
|
||||
const projectId = ref('project-1');
|
||||
const agent = ref<AgentResource | null>({
|
||||
id: 'inline:node-1',
|
||||
skills: {},
|
||||
skillHashes: {},
|
||||
} as unknown as AgentResource);
|
||||
vi.mocked(createAgentSkill).mockResolvedValue({
|
||||
id: 'skill_new',
|
||||
skill: { ...triage, name: 'New Skill' },
|
||||
skillHash: 'hash-new',
|
||||
versionId: 'v2',
|
||||
});
|
||||
const { uiStore, actions, localConfig } = setup({
|
||||
agent,
|
||||
projectId,
|
||||
refreshAgentAfterMutation: async () => await refresh,
|
||||
});
|
||||
|
||||
actions.onOpenAddSkillModal();
|
||||
const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
|
||||
modalData.onConfirm({ skill: { ...triage, name: 'New Skill' } });
|
||||
await flushPromises();
|
||||
projectId.value = 'project-2';
|
||||
agent.value = { id: 'inline:node-1', skills: {} } as unknown as AgentResource;
|
||||
localConfig.value = makeConfig();
|
||||
releaseRefresh(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(localConfig.value.skills).toBeUndefined();
|
||||
expect(agent.value.skills).toEqual({});
|
||||
});
|
||||
|
||||
it('reports a refresh failure without applying a created skill ref to stale config', async () => {
|
||||
const agent = ref<AgentResource | null>({
|
||||
id: 'inline:node-1',
|
||||
skills: {},
|
||||
skillHashes: {},
|
||||
} as unknown as AgentResource);
|
||||
const refreshError = new Error('refresh failed');
|
||||
vi.mocked(createAgentSkill).mockResolvedValue({
|
||||
id: 'skill_new',
|
||||
skill: { ...triage, name: 'New Skill' },
|
||||
skillHash: 'hash-new',
|
||||
versionId: 'v2',
|
||||
});
|
||||
const { uiStore, actions, localConfig } = setup({
|
||||
agent,
|
||||
refreshAgentAfterMutation: async () => await Promise.reject(refreshError),
|
||||
});
|
||||
|
||||
actions.onOpenAddSkillModal();
|
||||
const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
|
||||
modalData.onConfirm({ skill: { ...triage, name: 'New Skill' } });
|
||||
await flushPromises();
|
||||
|
||||
expect(showErrorSpy).toHaveBeenCalledWith(refreshError, 'agents.builder.loadError');
|
||||
expect(localConfig.value?.skills).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not create the skill when persisting the agent fails', async () => {
|
||||
const { uiStore, actions } = setup({
|
||||
ensureAgentPersisted: async () => await Promise.reject(new Error('create failed')),
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export const AgentModalTestStub = defineComponent({
|
||||
name: 'AgentModal',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
open: { type: Boolean, default: true },
|
||||
title: { type: String, default: '' },
|
||||
editableTitle: { type: Boolean, default: false },
|
||||
showBack: { type: Boolean, default: false },
|
||||
showFooter: { type: Boolean, default: undefined },
|
||||
showCancel: { type: Boolean, default: true },
|
||||
bodyFlush: { type: Boolean, default: false },
|
||||
busy: { type: Boolean, default: false },
|
||||
size: { type: String, default: '2xlarge' },
|
||||
trapFocus: { type: Boolean, default: true },
|
||||
disableOutsidePointerEvents: { type: Boolean, default: true },
|
||||
},
|
||||
emits: ['update:open', 'update:title', 'back', 'interactOutside'],
|
||||
template: `
|
||||
<section
|
||||
v-if="open"
|
||||
v-bind="$attrs"
|
||||
role="dialog"
|
||||
:data-busy="busy"
|
||||
:data-size="size"
|
||||
:data-trap-focus="trapFocus"
|
||||
:data-disable-outside-pointer-events="disableOutsidePointerEvents"
|
||||
:data-body-flush="bodyFlush"
|
||||
>
|
||||
<header>
|
||||
<button
|
||||
v-if="showBack"
|
||||
data-testid="agent-modal-back"
|
||||
:disabled="busy"
|
||||
@click="$emit('back')"
|
||||
/>
|
||||
<input
|
||||
v-if="editableTitle"
|
||||
:value="title"
|
||||
:disabled="busy"
|
||||
data-testid="agent-modal-title-input"
|
||||
@input="$emit('update:title', $event.target.value)"
|
||||
/>
|
||||
<h2 v-else>{{ title }}</h2>
|
||||
<slot name="headerActions" />
|
||||
<button
|
||||
data-testid="dialog-close-button"
|
||||
:disabled="busy"
|
||||
@click="$emit('update:open', false)"
|
||||
/>
|
||||
</header>
|
||||
<div v-if="!trapFocus" data-testid="nested-credential-focus-scope" />
|
||||
<main><slot /></main>
|
||||
<footer
|
||||
v-if="
|
||||
showFooter === true ||
|
||||
(showFooter !== false &&
|
||||
($slots.footerLeft ||
|
||||
$slots.footerBeforeCancel ||
|
||||
$slots.footerActions ||
|
||||
$slots.footer))
|
||||
"
|
||||
>
|
||||
<slot name="footer">
|
||||
<slot name="footerLeft" />
|
||||
<div data-testid="agent-modal-footer-actions">
|
||||
<slot name="footerBeforeCancel" />
|
||||
<button
|
||||
v-if="showCancel"
|
||||
data-testid="agent-modal-cancel"
|
||||
:disabled="busy"
|
||||
@click="$emit('update:open', false)"
|
||||
>Cancel</button>
|
||||
<slot name="footerActions" />
|
||||
</div>
|
||||
</slot>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
});
|
||||
|
||||
export const AgentModalMultiStepTestStub = defineComponent({
|
||||
name: 'AgentModalMultiStep',
|
||||
inheritAttrs: false,
|
||||
components: { AgentModalTestStub },
|
||||
props: {
|
||||
open: { type: Boolean, default: true },
|
||||
title: { type: String, default: '' },
|
||||
editableTitle: { type: Boolean, default: false },
|
||||
showBack: { type: Boolean, default: false },
|
||||
showFooter: { type: Boolean, default: undefined },
|
||||
showCancel: { type: Boolean, default: true },
|
||||
bodyFlush: { type: Boolean, default: false },
|
||||
busy: { type: Boolean, default: false },
|
||||
size: { type: String, default: '2xlarge' },
|
||||
trapFocus: { type: Boolean, default: true },
|
||||
disableOutsidePointerEvents: { type: Boolean, default: true },
|
||||
},
|
||||
emits: ['update:open', 'update:title', 'back', 'interactOutside'],
|
||||
template: `
|
||||
<AgentModalTestStub
|
||||
v-bind="$props"
|
||||
v-bind="$attrs"
|
||||
@update:open="$emit('update:open', $event)"
|
||||
@update:title="$emit('update:title', $event)"
|
||||
@back="$emit('back')"
|
||||
>
|
||||
<template #headerActions><slot name="headerActions" /></template>
|
||||
<slot />
|
||||
<template #footerLeft><slot name="footerLeft" /></template>
|
||||
<template #footerBeforeCancel><slot name="footerBeforeCancel" /></template>
|
||||
<template #footerActions><slot name="footerActions" /></template>
|
||||
<template #footer><slot name="footer" /></template>
|
||||
</AgentModalTestStub>
|
||||
`,
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
# Agent design language
|
||||
|
||||
This document is the source of truth for Agent-only interface patterns in this
|
||||
module. It starts with modal patterns. Add other reusable Agent patterns as the
|
||||
interface develops.
|
||||
|
||||
## Scope
|
||||
|
||||
- Keep Agent-specific components and behavior in this module.
|
||||
- Use Design System components and CSS variables.
|
||||
- Do not add or change a global Design System primitive for an Agent-only need.
|
||||
- Put all user-facing text in i18n.
|
||||
- Test responsive layouts at 375 by 667 pixels and in light and dark themes.
|
||||
|
||||
## Modal patterns
|
||||
|
||||
### Canonical components
|
||||
|
||||
Use these components:
|
||||
|
||||
- `components/modals/AgentModal.vue`
|
||||
- `components/modals/AgentModalMultiStep.vue`
|
||||
|
||||
`AgentModal` owns the dialog shell. It owns the title, Back, Close, Cancel, body
|
||||
scroll, and footer layout. `AgentModalMultiStep` adds stable picker layout and a
|
||||
subtle step transition. The feature component owns its data and step history.
|
||||
|
||||
Do not migrate `AgentMemoryPanel.vue` into this pattern. Memory needs separate
|
||||
design work.
|
||||
|
||||
### Layout contract
|
||||
|
||||
| Area | Rule |
|
||||
| ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Width | Use `2xlarge` by default. Keep one width for all steps. |
|
||||
| Header | Keep the header's bottom divider. Put Back on the left and Close on the right. |
|
||||
| Title | Use an editable local name when the asset supports one. Do not add an asset icon. |
|
||||
| Body | Let `AgentModal` own the outer body inset. Do not repeat it on the first content wrapper. Focus the first body field. If there is no body control, use the dialog's default focus. Keep the title out of the initial focus order. Scroll the body only. Keep its scrollbar visible. |
|
||||
| Footer | Do not add a divider. Put ghost Remove on the left. Put Cancel before the primary action on the right. |
|
||||
| Responsive | Support 375 by 667 pixels. Stack footer actions when necessary. |
|
||||
|
||||
Use CSS variables for all sizes, spacing, colors, and motion. Do not add a new
|
||||
global dialog primitive.
|
||||
|
||||
Use `fit` for Skills. Use a 52rem content width. Keep it narrower than `full`.
|
||||
Use `full` only when the user explicitly asks for the extra workspace.
|
||||
|
||||
Keep the header and footer fixed. Let the body scroll. Keep scrollbars visible
|
||||
when the body or nested content can scroll. The Agent shell is the only scroll
|
||||
owner for normal configuration forms. Do not put fixed heights or nested
|
||||
scrollbars on MCP, node, or workflow configuration content. A picker can use a
|
||||
stable minimum height. A configuration step must use its natural height.
|
||||
|
||||
Do not add top padding or a top margin to a modal's first content wrapper. The
|
||||
shell supplies that space. Use the flush body only for a full-bleed workspace.
|
||||
The workspace must then own all of its edge spacing.
|
||||
|
||||
When a nested credential dialog is open, release the parent focus trap and
|
||||
block parent dismissal. The nested dialog owns Escape until it closes.
|
||||
|
||||
### Title contract
|
||||
|
||||
Use the configured local name for schedules, skills, node tools, MCP servers,
|
||||
workflow tools, and vector stores. Give new items a valid default name. Show the
|
||||
pencil on hover. Keep the title clickable.
|
||||
|
||||
Do not show a second Name field in the body. Show title validation next to the
|
||||
title after the user selects Save.
|
||||
|
||||
Keep the title read-only for external entities. Channels use the integration
|
||||
label. Sub-agents use the selected Agent name. Custom tools stay read-only
|
||||
because their reference schema has no local name.
|
||||
|
||||
### Action contract
|
||||
|
||||
Normal configuration has a Cancel button before the primary action. Cancel,
|
||||
Close, Escape, and outside click discard changes. Back discards the current
|
||||
step and returns to the preserved picker.
|
||||
|
||||
Put a non-destructive secondary action before Cancel. For schedules, use the
|
||||
order `Preview`, `Cancel`, `Save`, and use the ghost variant for Preview.
|
||||
|
||||
Save stays enabled until a request starts. An invalid Save shows inline errors.
|
||||
Successful Save, Add, and Remove actions close silently. Use a toast for a
|
||||
server error. Keep a warning toast when the warning contains information that
|
||||
the user must act on.
|
||||
|
||||
Existing removable items use a ghost bottom-left button with `trash-2`. Use an
|
||||
explicit label for the asset. Examples include `Remove schedule`, `Remove MCP`,
|
||||
and `Remove workflow`. New items and picker rows do not show Remove.
|
||||
|
||||
### Status contract
|
||||
|
||||
Use the same status treatment in Agent picker rows. Show a success check before
|
||||
`Connected` or `Added`. Use `Added` for selected Sub-agents. Use muted `2xs`
|
||||
text and `3xs` spacing. Keep warning and failure indicators distinct.
|
||||
|
||||
### Multi-step contract
|
||||
|
||||
Use the multi-step component for Channels, Sub-agents, Tools, MCP servers,
|
||||
Workflows, and Vector stores.
|
||||
|
||||
Keep the picker mounted. Preserve its search query, selected category, and
|
||||
scroll position. Always show the search input. Put a create row first when the
|
||||
user can create the asset. Workflows use `Create workflow`. Sub-agents use
|
||||
`Create agent`. Give the create row a short subtitle that describes the action.
|
||||
Keep the outer modal body fixed on picker steps. Show a list scrollbar only
|
||||
when the list content overflows. Do not show a scrollbar for an empty state.
|
||||
|
||||
Put an explicit action in every selectable row. The action must name the
|
||||
outcome. Use `Add node`, `Add MCP`, `Add workflow`, or the matching asset label.
|
||||
When the list has no items or no search matches, show a centered, asset-specific
|
||||
message. Do not use a drop zone or a decorative empty-state icon for a picker.
|
||||
Use less space above the embedded search input and more space between the input
|
||||
and the tabs.
|
||||
|
||||
Show Back for an add flow after the user leaves the picker. Do not show Back for
|
||||
a direct pill edit. Save, Cancel, and Close exit the complete flow.
|
||||
|
||||
### Exceptions
|
||||
|
||||
| Case | Exception |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Skills | Use `fit` with a 52rem content width. Keep it narrower than `full`. Use the flush Agent body so the file workspace does not get double padding. Keep the file navigation and editor inside the scrolling body. |
|
||||
| Dangerous confirmation | Keep its explicit Cancel and primary actions. Examples are Agent, file, and session deletion; unpublish; revert; eval regeneration; LangSmith export; and managed Slack app removal. |
|
||||
| Managed Slack app removal | Keep the stacked confirmation because it can delete an external resource. |
|
||||
| Small utility dialog | Keep an appropriate small size. Use Cancel and one bottom-right primary action. JSON import and Agent duplication use this rule. |
|
||||
| Channel platform setup | A platform-owned setup action can stay in the inline modal content. The Agent shell still owns navigation and dismissal. |
|
||||
| Memory | Do not migrate Memory into this pattern in this change. |
|
||||
|
||||
### Review checklist
|
||||
|
||||
- Confirm the modal uses `AgentModal` or `AgentModalMultiStep`.
|
||||
- Confirm the title has no asset icon.
|
||||
- Confirm an editable title replaces a duplicate Name field.
|
||||
- Confirm Back appears only when a previous step exists.
|
||||
- Confirm Close is top-right and disabled during a request.
|
||||
- Confirm Cancel appears before the primary action.
|
||||
- Confirm Remove is explicit, uses ghost styling, has `trash-2`, and appears only for existing items.
|
||||
- Confirm Save is bottom-right and reveals inline errors.
|
||||
- Confirm picker state survives Back.
|
||||
- Confirm search stays visible and empty copy names the asset.
|
||||
- Confirm a permitted create action appears before the list.
|
||||
- Confirm success closes silently.
|
||||
- Confirm UI text uses i18n.
|
||||
- Confirm the layout works at 375 by 667 pixels in light and dark themes.
|
||||
|
||||
## Extend this document
|
||||
|
||||
Add a section when an Agent-specific pattern applies to two or more Agent
|
||||
surfaces. Keep implementation details with the owning pattern. Do not duplicate
|
||||
global Design System guidance.
|
||||
+30
-9
@@ -12,6 +12,8 @@ import { computed, onMounted, watch } from 'vue';
|
||||
import type { AgentJsonConfig, AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types';
|
||||
import type { AgentSkill, CustomToolEntry } from '../types';
|
||||
import { useProjectAgentsList } from '../composables/useProjectAgentsList';
|
||||
import { useAgentPermissions } from '../composables/useAgentPermissions';
|
||||
import { useCreateAgent } from '../composables/useCreateAgent';
|
||||
import { useAgentCapabilityIssueMessages } from '../composables/useAgentCapabilityIssueMessages';
|
||||
import { toolRefToNode } from '../composables/useAgentToolRefAdapter';
|
||||
import { AGENT_SUB_AGENTS_MODAL_KEY } from '../constants';
|
||||
@@ -85,8 +87,10 @@ const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const uiStore = useUIStore();
|
||||
const { createAgent } = useCreateAgent();
|
||||
|
||||
const projectIdRef = computed(() => props.projectId);
|
||||
const { canCreate: canCreateAgent } = useAgentPermissions(projectIdRef);
|
||||
const {
|
||||
list: projectAgents,
|
||||
ensureLoaded: ensureProjectAgentsLoaded,
|
||||
@@ -100,9 +104,7 @@ const selectedSubAgentIds = computed(() =>
|
||||
);
|
||||
const selectedSubAgentIdSet = computed(() => new Set(selectedSubAgentIds.value));
|
||||
const availableSubAgents = computed(() =>
|
||||
(projectAgents.value ?? []).filter(
|
||||
(agent) => agent.id !== props.agentId && !selectedSubAgentIdSet.value.has(agent.id),
|
||||
),
|
||||
(projectAgents.value ?? []).filter((agent) => agent.id !== props.agentId),
|
||||
);
|
||||
const selectedSubAgents = computed(() =>
|
||||
selectedSubAgentRefs.value.map(({ agentId, useWhen }) => {
|
||||
@@ -396,14 +398,33 @@ async function openSubAgentsModal() {
|
||||
uiStore.openModalWithData({
|
||||
name: AGENT_SUB_AGENTS_MODAL_KEY,
|
||||
data: {
|
||||
agents: availableSubAgents.value.map(({ id, name }) => ({
|
||||
id,
|
||||
name,
|
||||
})),
|
||||
agents: availableSubAgents.value.map(({ id, name }) => {
|
||||
const selectedRef = selectedSubAgentRefs.value.find((ref) => ref.agentId === id);
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
added: Boolean(selectedRef),
|
||||
useWhen: selectedRef?.useWhen,
|
||||
invalidReasons: subAgentIssueMessages.value.get(id) ?? [],
|
||||
agentHref: `/projects/${encodeURIComponent(props.projectId)}/agents/${encodeURIComponent(id)}`,
|
||||
};
|
||||
}),
|
||||
onCreateAgent: canCreateAgent.value
|
||||
? () => createAgent('button', props.projectId)
|
||||
: undefined,
|
||||
onConfirm: ({ agentId, useWhen }: { agentId: string; useWhen?: string }) => {
|
||||
if (selectedSubAgentIdSet.value.has(agentId)) return;
|
||||
const nextRef = toSubAgentRef(agentId, useWhen);
|
||||
if (selectedSubAgentIdSet.value.has(agentId)) {
|
||||
emitSubAgentRefs(
|
||||
selectedSubAgentRefs.value.map((ref) => (ref.agentId === agentId ? nextRef : ref)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
emitSubAgentRefs([...selectedSubAgentRefs.value, toSubAgentRef(agentId, useWhen)]);
|
||||
emitSubAgentRefs([...selectedSubAgentRefs.value, nextRef]);
|
||||
},
|
||||
onRemove: (agentId: string) => {
|
||||
emitSubAgentRefs(selectedSubAgentRefs.value.filter((ref) => ref.agentId !== agentId));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+53
-78
@@ -1,13 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
N8nButton,
|
||||
N8nDropdownMenu,
|
||||
N8nIcon,
|
||||
N8nLoading,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
updatedIconSet,
|
||||
type DropdownMenuItemProps,
|
||||
type IconName,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
@@ -15,8 +13,6 @@ import type { ChatIntegrationDescriptor } from '@n8n/api-types';
|
||||
import { computed } from 'vue';
|
||||
import type { AgentChannelConnectAction } from '../channels/types';
|
||||
|
||||
type ChannelAction = 'edit' | 'disconnect';
|
||||
|
||||
interface Props {
|
||||
integration: ChatIntegrationDescriptor;
|
||||
configured: boolean;
|
||||
@@ -37,26 +33,10 @@ const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
setup: [channelType: string];
|
||||
edit: [channelType: string];
|
||||
disconnect: [channelType: string];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const configuredActions = computed<Array<DropdownMenuItemProps<ChannelAction>>>(() => {
|
||||
const actions: Array<DropdownMenuItemProps<ChannelAction>> = [
|
||||
{
|
||||
id: 'edit',
|
||||
label: i18n.baseText('generic.edit'),
|
||||
},
|
||||
{
|
||||
id: 'disconnect',
|
||||
label: i18n.baseText('generic.disconnect'),
|
||||
},
|
||||
];
|
||||
|
||||
return actions;
|
||||
});
|
||||
|
||||
function isIconName(icon: string): icon is IconName {
|
||||
return icon in updatedIconSet;
|
||||
}
|
||||
@@ -76,15 +56,6 @@ const statusTooltip = computed(() => {
|
||||
if (!props.notRunning) return '';
|
||||
return props.runtimeError || i18n.baseText('agents.channels.modal.notRunning.tooltip');
|
||||
});
|
||||
|
||||
function handleConfiguredAction(action: ChannelAction) {
|
||||
if (action === 'edit') {
|
||||
emit('edit', props.integration.type);
|
||||
return;
|
||||
}
|
||||
|
||||
emit('disconnect', props.integration.type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -116,36 +87,32 @@ function handleConfiguredAction(action: ChannelAction) {
|
||||
</div>
|
||||
|
||||
<div :class="$style.channelActions">
|
||||
<N8nDropdownMenu
|
||||
<N8nTooltip
|
||||
v-if="configured"
|
||||
:items="configuredActions"
|
||||
placement="bottom-end"
|
||||
:modal="false"
|
||||
@select="handleConfiguredAction"
|
||||
:content="statusTooltip"
|
||||
:disabled="!notRunning"
|
||||
placement="top"
|
||||
>
|
||||
<template #trigger>
|
||||
<N8nTooltip :content="statusTooltip" :disabled="!notRunning" placement="top">
|
||||
<N8nButton variant="ghost" size="medium" :class="$style.connectedTrigger">
|
||||
<div
|
||||
v-if="connected"
|
||||
:class="$style.connectedDotContainer"
|
||||
data-testid="agent-channel-connected-indicator"
|
||||
>
|
||||
<span :class="[$style.connectedDot, $style.ping]" />
|
||||
<span :class="$style.connectedDot" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="notRunning"
|
||||
:class="$style.connectedDotContainer"
|
||||
data-testid="agent-channel-not-running-indicator"
|
||||
>
|
||||
<span :class="[$style.connectedDot, $style.notRunningDot]" />
|
||||
</div>
|
||||
{{ statusLabel }}
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
</N8nDropdownMenu>
|
||||
<button
|
||||
type="button"
|
||||
:class="$style.connectedTrigger"
|
||||
@click="emit('edit', integration.type)"
|
||||
>
|
||||
<span
|
||||
v-if="!notRunning"
|
||||
:class="$style.connectedIcon"
|
||||
data-testid="agent-channel-connected-indicator"
|
||||
>
|
||||
<N8nIcon icon="check" :size="14" aria-hidden="true" />
|
||||
</span>
|
||||
<span
|
||||
v-else-if="notRunning"
|
||||
:class="$style.notRunningIndicator"
|
||||
data-testid="agent-channel-not-running-indicator"
|
||||
/>
|
||||
{{ statusLabel }}
|
||||
</button>
|
||||
</N8nTooltip>
|
||||
<N8nButton
|
||||
v-else
|
||||
variant="subtle"
|
||||
@@ -161,7 +128,7 @@ function handleConfiguredAction(action: ChannelAction) {
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
@use '@n8n/design-system/css/mixins/motion';
|
||||
@use '@n8n/design-system/css/mixins/focus';
|
||||
|
||||
.channelItem {
|
||||
display: flex;
|
||||
@@ -172,8 +139,8 @@ function handleConfiguredAction(action: ChannelAction) {
|
||||
|
||||
.iconWrapper {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: var(--spacing--xl);
|
||||
height: var(--spacing--xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -188,8 +155,8 @@ function handleConfiguredAction(action: ChannelAction) {
|
||||
}
|
||||
|
||||
.buttonSkeleton {
|
||||
height: 32px;
|
||||
width: 80px;
|
||||
height: var(--spacing--xl);
|
||||
width: calc(var(--spacing--xl) * 2.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -225,27 +192,35 @@ function handleConfiguredAction(action: ChannelAction) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
padding: var(--spacing--4xs) var(--spacing--3xs);
|
||||
border: 0;
|
||||
border-radius: var(--radius--2xs);
|
||||
background: none;
|
||||
color: var(--color--text--tint-1);
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size--2xs);
|
||||
font-weight: var(--font-weight--regular);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
.connectedDotContainer {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
> * {
|
||||
grid-area: 1 / 1;
|
||||
&:hover {
|
||||
background: var(--color--background--light-1);
|
||||
}
|
||||
|
||||
@include focus.focus-visible-ring;
|
||||
}
|
||||
|
||||
.connectedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: var(--radius--full);
|
||||
background: var(--color--green-500);
|
||||
.connectedIcon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--color--success);
|
||||
}
|
||||
.notRunningDot {
|
||||
|
||||
.notRunningIndicator {
|
||||
flex-shrink: 0;
|
||||
width: var(--spacing--3xs);
|
||||
height: var(--spacing--3xs);
|
||||
border-radius: var(--radius--full);
|
||||
background: var(--color--danger);
|
||||
}
|
||||
.ping {
|
||||
@include motion.ping;
|
||||
}
|
||||
</style>
|
||||
|
||||
+124
-304
@@ -1,19 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nDialog,
|
||||
N8nDialogFooter,
|
||||
N8nDialogHeader,
|
||||
N8nDialogTitle,
|
||||
N8nIcon,
|
||||
N8nIconButton,
|
||||
updatedIconSet,
|
||||
type IconName,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { AgentApproval } from '@n8n/api-types';
|
||||
import { FocusScope } from 'reka-ui';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
@@ -31,6 +20,7 @@ import { useAgentIntegrationStatus } from '../composables/useAgentIntegrationSta
|
||||
import { useAgentIntegrationsCatalog } from '../composables/useAgentIntegrationsCatalog';
|
||||
import AgentChannelApprovalSetting from './AgentChannelApprovalSetting.vue';
|
||||
import AgentChannelListItem from './AgentChannelListItem.vue';
|
||||
import AgentModalMultiStep from './modals/AgentModalMultiStep.vue';
|
||||
|
||||
export type ChannelView = AgentChannelView;
|
||||
|
||||
@@ -79,9 +69,11 @@ const {
|
||||
} = useAgentIntegrationStatus(props.projectId, props.agentId);
|
||||
|
||||
const currentView = ref<ChannelView>(props.view);
|
||||
const openedFromList = ref(props.view === 'list');
|
||||
const viewSession = ref(0);
|
||||
const credentialIdAtEditOpen = ref('');
|
||||
const channelActionInFlight = ref(false);
|
||||
const saveAttempted = ref(false);
|
||||
const pendingDisconnect = ref<{
|
||||
channelType: string;
|
||||
credentialId: string;
|
||||
@@ -224,7 +216,6 @@ const headerContentComponent = computed(() => {
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const canClose = computed(() => !actionInFlight.value);
|
||||
function prepareChannelEdit(channelType: string | null) {
|
||||
captureConnectedCredential(channelType);
|
||||
channelApproval.value = channelType ? integrationApproval.value[channelType] : undefined;
|
||||
@@ -248,28 +239,15 @@ const showFooterActions = computed(() => isEditMode.value && selectedChannelType
|
||||
const currentChannelCredentialId = computed(() =>
|
||||
getChannelCredentialId(selectedChannelType.value),
|
||||
);
|
||||
const canSaveChannelConfig = computed(() => {
|
||||
return (
|
||||
selectedChannelType.value !== null &&
|
||||
currentChannelCredentialId.value.length > 0 &&
|
||||
!channelViewLoading.value &&
|
||||
!channelViewRef.value?.validationError &&
|
||||
channelApprovalValid.value
|
||||
);
|
||||
});
|
||||
|
||||
function isIconName(icon: string): icon is IconName {
|
||||
return icon in updatedIconSet;
|
||||
}
|
||||
|
||||
const headerText = computed(() => {
|
||||
const isListMode = currentView.value === 'list';
|
||||
const channel = selectedChannelType.value;
|
||||
if (channel && !isListMode) {
|
||||
return channel;
|
||||
return currentIntegration.value?.label ?? channel;
|
||||
}
|
||||
return i18n.baseText('agents.channels.modal.title');
|
||||
});
|
||||
const currentStep = computed(() => (currentView.value === 'list' ? 'list' : 'configure'));
|
||||
|
||||
function isConnected(channelType: string): boolean {
|
||||
return isIntegrationConnected(channelType);
|
||||
@@ -305,24 +283,25 @@ function connectAction(channelType: string) {
|
||||
|
||||
function goToSetup(channelType: string) {
|
||||
clearIntegrationError(channelType);
|
||||
openedFromList.value = true;
|
||||
saveAttempted.value = false;
|
||||
currentView.value = `${channelType}_setup`;
|
||||
}
|
||||
|
||||
function goToEdit(channelType: string) {
|
||||
prepareChannelEdit(channelType);
|
||||
openedFromList.value = true;
|
||||
saveAttempted.value = false;
|
||||
currentView.value = `${channelType}_edit`;
|
||||
}
|
||||
|
||||
function goBackToList() {
|
||||
if (actionInFlight.value) return;
|
||||
prepareChannelEdit(null);
|
||||
saveAttempted.value = false;
|
||||
currentView.value = 'list';
|
||||
}
|
||||
|
||||
function handleListDisconnect(channelType: string) {
|
||||
requestDisconnect(channelType, connectedCredentials.value[channelType] ?? '', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close once a flow has finished its own work. Separate from `closeModal`
|
||||
* because a platform reports `connected` from inside its setup flow, while that
|
||||
@@ -332,11 +311,6 @@ function completeAndClose() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (actionInFlight.value) return;
|
||||
completeAndClose();
|
||||
}
|
||||
|
||||
function handleModalOpenUpdate(isOpen: boolean) {
|
||||
if (!isOpen && actionInFlight.value) return;
|
||||
emit('update:open', isOpen);
|
||||
@@ -375,10 +349,12 @@ async function runBeforeSave(): Promise<boolean> {
|
||||
|
||||
async function saveChannelConfig() {
|
||||
if (actionInFlight.value) return;
|
||||
saveAttempted.value = true;
|
||||
const channelType = selectedChannelType.value;
|
||||
const credentialId = currentChannelCredentialId.value;
|
||||
if (!channelType || !credentialId) return;
|
||||
if (channelViewRef.value?.validationError) return;
|
||||
if (!channelApprovalValid.value) return;
|
||||
|
||||
// Swapping the credential of a configured channel is one request: the
|
||||
// backend brings the new channel up, swaps both entries in a single write,
|
||||
@@ -525,6 +501,8 @@ watch(
|
||||
if (isOpen) {
|
||||
viewSession.value += 1;
|
||||
void loadChannelState();
|
||||
openedFromList.value = props.view === 'list';
|
||||
saveAttempted.value = false;
|
||||
currentView.value = props.view;
|
||||
} else {
|
||||
captureConnectedCredential(null);
|
||||
@@ -535,180 +513,125 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nDialog
|
||||
<AgentModalMultiStep
|
||||
:open="open"
|
||||
size="2xlarge"
|
||||
:step="currentStep"
|
||||
:title="headerText"
|
||||
:show-back="currentView !== 'list' && openedFromList"
|
||||
:show-footer="showFooterActions"
|
||||
:busy="actionInFlight"
|
||||
:trap-focus="!credentialModalOpen"
|
||||
:disable-outside-pointer-events="!credentialModalOpen"
|
||||
:show-close-button="false"
|
||||
@interact-outside="handleInteractOutside"
|
||||
@update:open="handleModalOpenUpdate"
|
||||
@back="goBackToList"
|
||||
>
|
||||
<FocusScope
|
||||
v-if="credentialModalOpen"
|
||||
as-child
|
||||
@mount-auto-focus.prevent
|
||||
@unmount-auto-focus.prevent
|
||||
>
|
||||
<span hidden aria-hidden="true" />
|
||||
</FocusScope>
|
||||
|
||||
<N8nDialogHeader :class="$style.customHeader">
|
||||
<Transition name="channel-header-fade" mode="out-in">
|
||||
<div v-if="currentView === 'list'" key="list" :class="$style.headerContent">
|
||||
<div :class="$style.headerTitle">
|
||||
<N8nDialogTitle>{{ headerText }}</N8nDialogTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :key="currentView" :class="$style.headerContent">
|
||||
<N8nIconButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
icon-size="medium"
|
||||
icon="arrow-left"
|
||||
:disabled="actionInFlight"
|
||||
:class="$style.backButton"
|
||||
data-testid="agent-channel-back"
|
||||
@click="goBackToList"
|
||||
>
|
||||
<template #icon>
|
||||
<N8nIcon icon="arrow-left" size="small" />
|
||||
</template>
|
||||
</N8nIconButton>
|
||||
<div :class="$style.headerTitle">
|
||||
<N8nIcon
|
||||
v-if="currentIntegration?.icon && isIconName(currentIntegration.icon)"
|
||||
:icon="currentIntegration.icon"
|
||||
size="large"
|
||||
/>
|
||||
<N8nDialogTitle>{{ headerText }}</N8nDialogTitle>
|
||||
</div>
|
||||
<div :class="$style.headerActions">
|
||||
<component
|
||||
:is="headerContentComponent"
|
||||
v-if="headerContentComponent"
|
||||
:runtime="currentRuntime"
|
||||
:disabled="headerContentDisabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<N8nIconButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
icon-size="medium"
|
||||
icon="x"
|
||||
:class="$style.closeButton"
|
||||
aria-label="Close dialog"
|
||||
data-test-id="dialog-close-button"
|
||||
:disabled="!canClose"
|
||||
@click="closeModal"
|
||||
<template #headerActions>
|
||||
<component
|
||||
:is="headerContentComponent"
|
||||
v-if="headerContentComponent"
|
||||
:runtime="currentRuntime"
|
||||
:disabled="headerContentDisabled"
|
||||
/>
|
||||
</N8nDialogHeader>
|
||||
</template>
|
||||
|
||||
<div data-testid="agent-channel-modal" :class="$style.container">
|
||||
<Transition name="channel-view-fade" mode="out-in">
|
||||
<div v-if="currentView === 'list'" key="list" :class="$style.listView">
|
||||
<ul :class="$style.channelList">
|
||||
<AgentChannelListItem
|
||||
v-for="integration in catalog"
|
||||
:key="integration.type"
|
||||
:integration="integration"
|
||||
:configured="isConfigured(integration.type)"
|
||||
:connected="isConnected(integration.type)"
|
||||
:not-running="hasRuntimeError(integration.type)"
|
||||
:runtime-error="runtimeErrors[integration.type]"
|
||||
:loading="listLoading"
|
||||
:connect-action="connectAction(integration.type)"
|
||||
@setup="goToSetup"
|
||||
@edit="goToEdit"
|
||||
@disconnect="handleListDisconnect"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-show="currentView === 'list'" key="list" :class="$style.listView">
|
||||
<ul :class="$style.channelList">
|
||||
<AgentChannelListItem
|
||||
v-for="integration in catalog"
|
||||
:key="integration.type"
|
||||
:integration="integration"
|
||||
:configured="isConfigured(integration.type)"
|
||||
:connected="isConnected(integration.type)"
|
||||
:not-running="hasRuntimeError(integration.type)"
|
||||
:runtime-error="runtimeErrors[integration.type]"
|
||||
:loading="listLoading"
|
||||
:connect-action="connectAction(integration.type)"
|
||||
@setup="goToSetup"
|
||||
@edit="goToEdit"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="currentIntegration"
|
||||
:key="`${isSetupMode ? 'setup' : 'edit'}-${currentView}`"
|
||||
:class="isSetupMode ? $style.setupView : $style.editView"
|
||||
<div
|
||||
v-if="currentView !== 'list' && currentIntegration"
|
||||
:key="`${isSetupMode ? 'setup' : 'edit'}-${currentView}`"
|
||||
:class="isSetupMode ? $style.setupView : $style.editView"
|
||||
>
|
||||
<component
|
||||
:is="isSetupMode ? currentPlatform.setupComponent : currentPlatform.editComponent"
|
||||
:key="viewSession"
|
||||
ref="channelViewRef"
|
||||
v-model="selectedCredentials[currentIntegration.type]"
|
||||
:mode="isSetupMode ? 'setup' : 'edit'"
|
||||
:integration="currentIntegration"
|
||||
:credentials="getCredentials(currentIntegration.type)"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:credentials-loading="credentialsLoading"
|
||||
:loading="isLoading(currentIntegration.type)"
|
||||
:connected="isConfigured(currentIntegration.type)"
|
||||
:connected-description="integrationConnectedText(currentIntegration.type)"
|
||||
:error-message="
|
||||
hasError(currentIntegration.type) ? errorMessages[currentIntegration.type] : ''
|
||||
"
|
||||
:error-is-conflict="errorIsConflict[currentIntegration.type]"
|
||||
:saved-settings="integrationSettings[currentIntegration.type]"
|
||||
:is-published="isPublished"
|
||||
:agent-name="agentId"
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
:force-new-credential="false"
|
||||
:simple-setup="simpleSetup"
|
||||
:runtime="currentRuntime"
|
||||
@create="createCredential"
|
||||
@edit="editCredential"
|
||||
@connect="saveChannelConfig"
|
||||
@connected="handlePlatformConnected"
|
||||
/>
|
||||
<N8nText
|
||||
v-if="saveAttempted && !currentChannelCredentialId"
|
||||
size="small"
|
||||
color="danger"
|
||||
data-testid="agent-channel-credential-required"
|
||||
>
|
||||
<component
|
||||
:is="isSetupMode ? currentPlatform.setupComponent : currentPlatform.editComponent"
|
||||
:key="viewSession"
|
||||
ref="channelViewRef"
|
||||
v-model="selectedCredentials[currentIntegration.type]"
|
||||
:mode="isSetupMode ? 'setup' : 'edit'"
|
||||
:integration="currentIntegration"
|
||||
:credentials="getCredentials(currentIntegration.type)"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:credentials-loading="credentialsLoading"
|
||||
:loading="isLoading(currentIntegration.type)"
|
||||
:connected="isConfigured(currentIntegration.type)"
|
||||
:connected-description="integrationConnectedText(currentIntegration.type)"
|
||||
:error-message="
|
||||
hasError(currentIntegration.type) ? errorMessages[currentIntegration.type] : ''
|
||||
"
|
||||
:error-is-conflict="errorIsConflict[currentIntegration.type]"
|
||||
:saved-settings="integrationSettings[currentIntegration.type]"
|
||||
:is-published="isPublished"
|
||||
:agent-name="agentId"
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
:force-new-credential="false"
|
||||
:simple-setup="simpleSetup"
|
||||
:runtime="currentRuntime"
|
||||
@create="createCredential"
|
||||
@edit="editCredential"
|
||||
@connect="saveChannelConfig"
|
||||
@connected="handlePlatformConnected"
|
||||
/>
|
||||
{{ i18n.baseText('agents.channels.modal.credentialRequired' as BaseTextKey) }}
|
||||
</N8nText>
|
||||
|
||||
<AgentChannelApprovalSetting
|
||||
v-if="isEditMode && approvableActions.length > 0"
|
||||
v-model="channelApproval"
|
||||
:actions="approvableActions"
|
||||
@update:valid="channelApprovalValid = $event"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
<AgentChannelApprovalSetting
|
||||
v-if="isEditMode && approvableActions.length > 0"
|
||||
v-model="channelApproval"
|
||||
:actions="approvableActions"
|
||||
@update:valid="channelApprovalValid = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="channel-footer-fade">
|
||||
<N8nDialogFooter v-if="showFooterActions" :class="$style.customFooter">
|
||||
<div :class="$style.footer">
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
:loading="selectedChannelType ? isLoading(selectedChannelType) : false"
|
||||
:disabled="actionInFlight || !selectedChannelType"
|
||||
data-testid="agent-channel-remove-channel"
|
||||
@click="removeCurrentChannel"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.modal.removeChannel') }}
|
||||
</N8nButton>
|
||||
<div :class="$style.footerActions">
|
||||
<N8nButton
|
||||
variant="outline"
|
||||
size="medium"
|
||||
:disabled="actionInFlight"
|
||||
@click="closeModal"
|
||||
>
|
||||
{{ i18n.baseText('generic.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
size="medium"
|
||||
:loading="actionInFlight"
|
||||
:disabled="!canSaveChannelConfig || actionInFlight"
|
||||
data-testid="agent-channel-save-channel-config"
|
||||
@click="saveChannelConfig"
|
||||
>
|
||||
{{ i18n.baseText('generic.save') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</N8nDialogFooter>
|
||||
</Transition>
|
||||
<template v-if="showFooterActions" #footerLeft>
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
:loading="selectedChannelType ? isLoading(selectedChannelType) : false"
|
||||
:disabled="actionInFlight || !selectedChannelType"
|
||||
data-testid="agent-channel-remove-channel"
|
||||
@click="removeCurrentChannel"
|
||||
>
|
||||
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
|
||||
{{ i18n.baseText('agents.channels.modal.removeChannel') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
<template v-if="showFooterActions" #footerActions>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
size="medium"
|
||||
:loading="actionInFlight"
|
||||
:disabled="!channelApprovalValid || actionInFlight"
|
||||
data-testid="agent-channel-save-channel-config"
|
||||
@click="saveChannelConfig"
|
||||
>
|
||||
{{ i18n.baseText('generic.save') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
<component
|
||||
:is="disconnectConfirmationComponent"
|
||||
v-if="pendingDisconnect && disconnectConfirmationComponent"
|
||||
@@ -717,66 +640,14 @@ watch(
|
||||
@cancel="pendingDisconnect = null"
|
||||
@confirm="confirmDisconnect"
|
||||
/>
|
||||
</N8nDialog>
|
||||
</AgentModalMultiStep>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
body:has([data-testid='agent-channel-modal'])
|
||||
.el-overlay:has([data-test-id='editCredential-modal']) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style module lang="scss">
|
||||
@use '@n8n/design-system/css/mixins/motion';
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--md);
|
||||
min-height: var(--height--5xl);
|
||||
aspect-ratio: 4/3;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
margin-bottom: calc(var(--spacing--lg) * -1);
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.customHeader {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing--md);
|
||||
padding-inline: var(--spacing--lg);
|
||||
padding-bottom: var(--spacing--md);
|
||||
height: var(--height--2xl);
|
||||
border-bottom: var(--border);
|
||||
margin-inline: calc(var(--spacing--lg) * -1);
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--md);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.listView {
|
||||
@@ -787,7 +658,7 @@ body:has([data-testid='agent-channel-modal'])
|
||||
.channelList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-block: var(--spacing--xs);
|
||||
padding-bottom: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.setupView,
|
||||
@@ -795,56 +666,5 @@ body:has([data-testid='agent-channel-modal'])
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--md);
|
||||
padding: var(--spacing--md) 0;
|
||||
}
|
||||
|
||||
.customFooter {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
padding: var(--spacing--md) var(--spacing--lg);
|
||||
border-top: var(--border);
|
||||
background: light-dark(var(--color--neutral-white), var(--color--neutral-800));
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: var(--spacing--2xs);
|
||||
height: var(--height--md);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
:global(.channel-view-fade-enter-active) {
|
||||
--animation--fade-in--duration: var(--duration--snappy);
|
||||
--animation--fade-in--translate: 0;
|
||||
@include motion.fade-in;
|
||||
}
|
||||
|
||||
:global(.channel-view-fade-leave-active) {
|
||||
--animation--fade-out--duration: var(--duration--snappy);
|
||||
|
||||
@include motion.fade-out;
|
||||
}
|
||||
|
||||
:global(.channel-header-fade-enter-active),
|
||||
:global(.channel-footer-fade-enter-active) {
|
||||
--animation--fade-in--duration: var(--duration--snappy);
|
||||
--animation--fade-in--translate: 0;
|
||||
|
||||
@include motion.fade-in;
|
||||
}
|
||||
|
||||
:global(.channel-header-fade-leave-active),
|
||||
:global(.channel-footer-fade-leave-active) {
|
||||
--animation--fade-out--duration: var(--duration--snappy);
|
||||
|
||||
@include motion.fade-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
+30
-36
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { N8nButton, N8nHeading, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import Modal from '@/app/components/Modal.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import AgentModal from './modals/AgentModal.vue';
|
||||
|
||||
export type AgentConfirmationModalData = {
|
||||
title: string;
|
||||
@@ -20,6 +20,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const uiStore = useUIStore();
|
||||
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
|
||||
const submitting = ref(false);
|
||||
|
||||
function closeModal() {
|
||||
@@ -43,38 +44,38 @@ async function onConfirm() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onBeforeClose() {
|
||||
async function onOpenChange(open: boolean) {
|
||||
if (open) return;
|
||||
const shouldClose = await props.data.onClose?.();
|
||||
return shouldClose !== false;
|
||||
if (shouldClose !== false) closeModal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal width="500px" :name="props.modalName" :before-close="onBeforeClose">
|
||||
<template #header>
|
||||
<N8nHeading tag="h2" size="xlarge">
|
||||
{{ props.data.title }}
|
||||
</N8nHeading>
|
||||
<AgentModal
|
||||
:open="modalOpen"
|
||||
:title="props.data.title"
|
||||
:busy="submitting"
|
||||
:show-cancel="false"
|
||||
size="large"
|
||||
data-testid="agent-confirmation-modal"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<div :class="$style.content">
|
||||
<N8nIcon :class="$style.icon" icon="triangle-alert" color="warning" size="xlarge" />
|
||||
<N8nText size="medium">
|
||||
{{ props.data.description }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<template #footerActions>
|
||||
<N8nButton variant="subtle" size="medium" :disabled="submitting" @click="onCancel">
|
||||
{{ props.data.cancelButtonText }}
|
||||
</N8nButton>
|
||||
<N8nButton variant="solid" size="medium" :loading="submitting" @click="onConfirm">
|
||||
{{ props.data.confirmButtonText }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.content">
|
||||
<N8nIcon :class="$style.icon" icon="triangle-alert" color="warning" size="xlarge" />
|
||||
<N8nText size="medium">
|
||||
{{ props.data.description }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<N8nButton variant="subtle" size="medium" :disabled="submitting" @click="onCancel">
|
||||
{{ props.data.cancelButtonText }}
|
||||
</N8nButton>
|
||||
<N8nButton variant="solid" size="medium" :loading="submitting" @click="onConfirm">
|
||||
{{ props.data.confirmButtonText }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</AgentModal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
@@ -89,11 +90,4 @@ async function onBeforeClose() {
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
+46
-51
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { N8nButton, N8nHeading, N8nInput, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import Modal from '@/app/components/Modal.vue';
|
||||
import { N8nButton, N8nInput, N8nText } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import AgentModal from './modals/AgentModal.vue';
|
||||
|
||||
export type AgentDuplicateModalData = {
|
||||
projectId: string;
|
||||
@@ -21,9 +21,11 @@ const props = defineProps<{
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
|
||||
|
||||
const name = ref('');
|
||||
const submitting = ref(false);
|
||||
const submitted = ref(false);
|
||||
|
||||
const trimmedName = computed(() => name.value.trim());
|
||||
|
||||
@@ -34,6 +36,16 @@ const isNameTaken = computed(
|
||||
const canConfirm = computed(
|
||||
() => trimmedName.value.length > 0 && !isNameTaken.value && !submitting.value,
|
||||
);
|
||||
const nameError = computed(() => {
|
||||
if (!trimmedName.value) {
|
||||
return i18n.baseText('agents.duplicate.modal.nameRequired' as BaseTextKey);
|
||||
}
|
||||
if (isNameTaken.value) return i18n.baseText('agents.duplicate.modal.button.nameTaken');
|
||||
return '';
|
||||
});
|
||||
const visibleNameError = computed(() =>
|
||||
submitted.value || isNameTaken.value ? nameError.value : '',
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.data.agentId,
|
||||
@@ -48,6 +60,7 @@ function closeModal() {
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
submitted.value = true;
|
||||
if (!canConfirm.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
@@ -62,51 +75,39 @@ async function onConfirm() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:name="modalName"
|
||||
width="420px"
|
||||
<AgentModal
|
||||
:open="modalOpen"
|
||||
:title="i18n.baseText('agents.duplicate.modal.name')"
|
||||
:busy="submitting"
|
||||
size="medium"
|
||||
data-testid="agent-duplicate-modal"
|
||||
:close-on-click-modal="!submitting"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
@update:open="!$event && closeModal()"
|
||||
>
|
||||
<template #header>
|
||||
<N8nHeading tag="h2" size="xlarge">
|
||||
{{ i18n.baseText('agents.duplicate.modal.name') }}
|
||||
</N8nHeading>
|
||||
<div :class="$style.content">
|
||||
<N8nInput
|
||||
v-model="name"
|
||||
:placeholder="i18n.baseText('agents.duplicate.modal.enterName')"
|
||||
:label="i18n.baseText('agents.duplicate.modal.enterName')"
|
||||
:required="true"
|
||||
data-testid="agent-duplicate-name-input"
|
||||
@enter="onConfirm"
|
||||
/>
|
||||
<N8nText v-if="visibleNameError" :class="$style.error" size="small">
|
||||
{{ visibleNameError }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<template #footerActions>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
:disabled="submitting"
|
||||
:loading="submitting"
|
||||
data-testid="agent-duplicate-confirm"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ i18n.baseText('agents.duplicate.modal.button.confirm') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.content">
|
||||
<N8nInput
|
||||
v-model="name"
|
||||
:placeholder="i18n.baseText('agents.duplicate.modal.enterName')"
|
||||
:label="i18n.baseText('agents.duplicate.modal.enterName')"
|
||||
:required="true"
|
||||
data-testid="agent-duplicate-name-input"
|
||||
@enter="onConfirm"
|
||||
/>
|
||||
<N8nText v-if="isNameTaken" :class="$style.error" size="small">
|
||||
{{ i18n.baseText('agents.duplicate.modal.button.nameTaken') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<N8nButton variant="subtle" :disabled="submitting" @click="closeModal">
|
||||
{{ i18n.baseText('generic.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
:disabled="!canConfirm"
|
||||
:loading="submitting"
|
||||
data-testid="agent-duplicate-confirm"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ i18n.baseText('agents.duplicate.modal.button.confirm') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</AgentModal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
@@ -119,10 +120,4 @@ async function onConfirm() {
|
||||
.error {
|
||||
color: var(--color--danger);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
+38
-45
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
import { AgentJsonConfigSchema } from '@n8n/api-types';
|
||||
import { N8nButton, N8nCallout, N8nText } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
|
||||
import Modal from '@/app/components/Modal.vue';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import type { AgentJsonConfig } from '../types';
|
||||
import AgentModal from './modals/AgentModal.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
@@ -15,6 +15,7 @@ const props = defineProps<{
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
|
||||
const parsedConfig = ref<AgentJsonConfig | null>(null);
|
||||
const errorMessage = ref('');
|
||||
const importing = ref(false);
|
||||
@@ -68,49 +69,47 @@ async function onConfirm() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:name="props.modalName"
|
||||
<AgentModal
|
||||
:open="modalOpen"
|
||||
:title="i18n.baseText('agents.builder.importJsonModal.title' as BaseTextKey)"
|
||||
width="520px"
|
||||
:busy="importing"
|
||||
size="large"
|
||||
data-testid="agent-json-import-modal"
|
||||
@update:open="!$event && closeModal()"
|
||||
>
|
||||
<template #content>
|
||||
<div :class="$style.content">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.importJsonModal.description' as BaseTextKey) }}
|
||||
<div :class="$style.content">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.importJsonModal.description' as BaseTextKey) }}
|
||||
</N8nText>
|
||||
|
||||
<label :class="$style.fileField">
|
||||
<N8nText size="small" :bold="true">
|
||||
{{ i18n.baseText('agents.builder.importJsonModal.fileLabel' as BaseTextKey) }}
|
||||
</N8nText>
|
||||
|
||||
<label :class="$style.fileField">
|
||||
<N8nText size="small" :bold="true">
|
||||
{{ i18n.baseText('agents.builder.importJsonModal.fileLabel' as BaseTextKey) }}
|
||||
</N8nText>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
data-testid="agent-json-import-file-input"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<N8nCallout v-if="errorMessage" theme="danger" data-testid="agent-json-import-error">
|
||||
{{ errorMessage }}
|
||||
</N8nCallout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<N8nButton variant="subtle" :label="i18n.baseText('generic.cancel')" @click="closeModal" />
|
||||
<N8nButton
|
||||
:label="i18n.baseText('agents.builder.importJsonModal.import' as BaseTextKey)"
|
||||
:disabled="!parsedConfig || importing"
|
||||
data-testid="agent-json-import-confirm"
|
||||
@click="onConfirm"
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
data-testid="agent-json-import-file-input"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<N8nCallout v-if="errorMessage" theme="danger" data-testid="agent-json-import-error">
|
||||
{{ errorMessage }}
|
||||
</N8nCallout>
|
||||
</div>
|
||||
|
||||
<template #footerActions>
|
||||
<N8nButton
|
||||
:label="i18n.baseText('agents.builder.importJsonModal.import' as BaseTextKey)"
|
||||
:disabled="!parsedConfig || importing"
|
||||
:loading="importing"
|
||||
data-testid="agent-json-import-confirm"
|
||||
@click="onConfirm"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
</AgentModal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
@@ -125,10 +124,4 @@ async function onConfirm() {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,12 +11,14 @@ const props = withDefaults(
|
||||
isRunnable: boolean;
|
||||
isPreviewOpen?: boolean;
|
||||
iconOnly?: boolean;
|
||||
variant?: 'subtle' | 'ghost';
|
||||
validationIssues?: AgentConfigValidationIssue[];
|
||||
testId?: string;
|
||||
}>(),
|
||||
{
|
||||
isPreviewOpen: false,
|
||||
iconOnly: false,
|
||||
variant: 'subtle',
|
||||
validationIssues: () => [],
|
||||
testId: undefined,
|
||||
},
|
||||
@@ -68,7 +70,7 @@ function onClick() {
|
||||
/>
|
||||
<N8nButton
|
||||
v-else
|
||||
variant="subtle"
|
||||
:variant="props.variant"
|
||||
size="medium"
|
||||
icon="flask-conical"
|
||||
:label="label"
|
||||
|
||||
@@ -216,4 +216,14 @@ function testIdForPath(path: string) {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.nav {
|
||||
width: 100%;
|
||||
max-height: calc(var(--height--5xl) * 2);
|
||||
border-right: 0;
|
||||
border-bottom: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,17 +4,16 @@ import {
|
||||
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
|
||||
AGENT_SKILL_REFERENCE_MAX_COUNT,
|
||||
} from '@n8n/api-types';
|
||||
import { N8nButton, N8nCallout, N8nHeading, N8nIcon } from '@n8n/design-system';
|
||||
import { N8nButton, N8nCallout, N8nIcon } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
|
||||
import Modal from '@/app/components/Modal.vue';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useAgentTelemetry } from '../composables/useAgentTelemetry';
|
||||
import type { AgentSkill } from '../types';
|
||||
import { normalizeAgentSkillForSave } from '../utils/agentSkill';
|
||||
import AgentSkillFileNav from './AgentSkillFileNav.vue';
|
||||
import AgentSkillViewer, { type AgentSkillAllowedToolOption } from './AgentSkillViewer.vue';
|
||||
import AgentModal from './modals/AgentModal.vue';
|
||||
|
||||
const SKILL_FILE = 'SKILL.md';
|
||||
|
||||
@@ -41,11 +40,23 @@ const props = defineProps<{
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const agentTelemetry = useAgentTelemetry();
|
||||
const { showMessage } = useToast();
|
||||
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
|
||||
|
||||
function getDefaultSkillName(): string {
|
||||
const baseName = i18n.baseText('agents.builder.skills.defaultName' as BaseTextKey);
|
||||
const existingNames = new Set(
|
||||
(props.data.existingSkillNames ?? []).map((name) => name.trim().toLowerCase()),
|
||||
);
|
||||
if (!existingNames.has(baseName.toLowerCase())) return baseName;
|
||||
|
||||
let suffix = 2;
|
||||
while (existingNames.has(`${baseName} ${suffix}`.toLowerCase())) suffix += 1;
|
||||
return `${baseName} ${suffix}`;
|
||||
}
|
||||
|
||||
const skill = ref<AgentSkill>(
|
||||
normalizeSkill({
|
||||
name: props.data.skill?.name ?? '',
|
||||
name: props.data.skill?.name ?? getDefaultSkillName(),
|
||||
description: props.data.skill?.description ?? '',
|
||||
instructions: props.data.skill?.instructions ?? '',
|
||||
...(props.data.skill?.allowedTools ? { allowedTools: props.data.skill.allowedTools } : {}),
|
||||
@@ -79,6 +90,12 @@ const validationErrors = computed<Partial<Record<keyof AgentSkill, string>>>(()
|
||||
errors.name = i18n.baseText('agents.builder.skills.validation.nameRequired');
|
||||
} else if (name.length > 128) {
|
||||
errors.name = i18n.baseText('agents.builder.skills.validation.nameMaxLength');
|
||||
} else if (
|
||||
(props.data.existingSkillNames ?? []).some(
|
||||
(existingName) => existingName.trim().toLowerCase() === name.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
errors.name = i18n.baseText('agents.builder.skills.validation.nameDuplicate');
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
@@ -104,6 +121,9 @@ const validationErrors = computed<Partial<Record<keyof AgentSkill, string>>>(()
|
||||
const visibleErrors = computed(() =>
|
||||
submitted.value || openedWithMissingContent.value ? validationErrors.value : {},
|
||||
);
|
||||
const visibleNameError = computed(() =>
|
||||
submitted.value ? (validationErrors.value.name ?? '') : '',
|
||||
);
|
||||
const canSave = computed(() => formIsValid.value);
|
||||
|
||||
function onSkillUpdate(updates: Partial<AgentSkill>) {
|
||||
@@ -177,19 +197,7 @@ function closeModal() {
|
||||
|
||||
function onSave() {
|
||||
submitted.value = true;
|
||||
if (!canSave.value) {
|
||||
const message =
|
||||
validationErrors.value.name ??
|
||||
validationErrors.value.description ??
|
||||
validationErrors.value.instructions ??
|
||||
validationErrors.value.references;
|
||||
showMessage({
|
||||
title: i18n.baseText('agents.builder.skills.saveError'),
|
||||
...(message ? { message } : {}),
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!canSave.value) return;
|
||||
|
||||
const payload = normalizeSkill({
|
||||
name: skill.value.name.trim(),
|
||||
@@ -211,98 +219,82 @@ function onRemove() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:name="props.modalName"
|
||||
width="1100px"
|
||||
:custom-class="$style.modal"
|
||||
<AgentModal
|
||||
:open="modalOpen"
|
||||
:title="skill.name"
|
||||
:title-placeholder="i18n.baseText('agents.builder.skills.name.placeholder')"
|
||||
:title-error="visibleNameError"
|
||||
:title-max-length="128"
|
||||
size="fit"
|
||||
body-flush
|
||||
editable-title
|
||||
data-testid="agent-skill-modal"
|
||||
@update:open="!$event && closeModal()"
|
||||
@update:title="onSkillUpdate({ name: $event })"
|
||||
>
|
||||
<template #header>
|
||||
<N8nHeading tag="h2" size="large">
|
||||
{{ i18n.baseText('agents.builder.skills.create.title') }}
|
||||
</N8nHeading>
|
||||
</template>
|
||||
<N8nCallout
|
||||
v-if="openedWithMissingContent"
|
||||
theme="warning"
|
||||
:class="$style.missingContentCallout"
|
||||
data-testid="agent-skill-missing-content-callout"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.skills.missingContent.callout' as BaseTextKey) }}
|
||||
</N8nCallout>
|
||||
<div :class="$style.content">
|
||||
<AgentSkillFileNav
|
||||
:skill="skill"
|
||||
:selected-path="selectedPath"
|
||||
:add-reference-disabled="!canAddReference"
|
||||
@add-reference="onAddReference"
|
||||
@remove-reference="onRemoveReference"
|
||||
@select="selectedPath = $event"
|
||||
/>
|
||||
<AgentSkillViewer
|
||||
:skill="skill"
|
||||
:available-tools="props.data.availableTools ?? []"
|
||||
:existing-skill-names="props.data.existingSkillNames ?? []"
|
||||
:selected-path="selectedPath"
|
||||
:errors="visibleErrors"
|
||||
:scrollable="false"
|
||||
:show-name-field="false"
|
||||
:show-validation-warnings="submitted || openedWithMissingContent"
|
||||
@import:skill="onImportSkill"
|
||||
@select:path="selectedPath = $event"
|
||||
@update:skill="onSkillUpdate"
|
||||
@update:valid="onValidUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #content>
|
||||
<N8nCallout
|
||||
v-if="openedWithMissingContent"
|
||||
theme="warning"
|
||||
data-testid="agent-skill-missing-content-callout"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.skills.missingContent.callout' as BaseTextKey) }}
|
||||
</N8nCallout>
|
||||
<div :class="$style.content">
|
||||
<AgentSkillFileNav
|
||||
:skill="skill"
|
||||
:selected-path="selectedPath"
|
||||
:add-reference-disabled="!canAddReference"
|
||||
@add-reference="onAddReference"
|
||||
@remove-reference="onRemoveReference"
|
||||
@select="selectedPath = $event"
|
||||
/>
|
||||
<AgentSkillViewer
|
||||
:skill="skill"
|
||||
:available-tools="props.data.availableTools ?? []"
|
||||
:existing-skill-names="props.data.existingSkillNames ?? []"
|
||||
:selected-path="selectedPath"
|
||||
:errors="visibleErrors"
|
||||
:scrollable="false"
|
||||
:show-validation-warnings="submitted || openedWithMissingContent"
|
||||
@import:skill="onImportSkill"
|
||||
@select:path="selectedPath = $event"
|
||||
@update:skill="onSkillUpdate"
|
||||
@update:valid="onValidUpdate"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="isEditing && data.onRemove" #footerLeft>
|
||||
<N8nButton variant="ghost" data-testid="agent-skill-remove" @click="onRemove">
|
||||
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
|
||||
{{ i18n.baseText('agents.builder.skills.remove') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<N8nButton
|
||||
v-if="isEditing && data.onRemove"
|
||||
variant="subtle"
|
||||
data-testid="agent-skill-remove"
|
||||
@click="onRemove"
|
||||
>
|
||||
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
|
||||
{{ i18n.baseText('agents.builder.skills.remove') }}
|
||||
</N8nButton>
|
||||
<div :class="$style.footerActions">
|
||||
<N8nButton variant="subtle" @click="closeModal">
|
||||
{{ i18n.baseText('agents.builder.skills.create.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nButton variant="solid" data-testid="agent-skill-create-save" @click="onSave">
|
||||
{{ i18n.baseText('agents.builder.skills.create.save') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
<template #footerActions>
|
||||
<N8nButton variant="solid" data-testid="agent-skill-create-save" @click="onSave">
|
||||
{{ i18n.baseText('generic.save') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</AgentModal>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.content {
|
||||
height: 620px;
|
||||
/* The Design System has no width preset between 2xlarge and full. */
|
||||
width: min(52rem, calc(100dvw - var(--spacing--lg) * 3));
|
||||
height: min(calc(70dvh - var(--spacing--lg)), calc(var(--height--5xl) * 6 - var(--spacing--lg)));
|
||||
min-height: 0;
|
||||
margin: 0 calc(-1 * var(--spacing--lg)) calc(-1 * var(--spacing--lg));
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal {
|
||||
:global(.modal-content) {
|
||||
overflow: hidden;
|
||||
.missingContentCallout {
|
||||
margin: var(--spacing--md) var(--spacing--lg) 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,9 +8,6 @@ import {
|
||||
} from '@n8n/api-types';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nDialog,
|
||||
N8nDialogHeader,
|
||||
N8nDialogTitle,
|
||||
N8nFormInput,
|
||||
N8nIcon,
|
||||
N8nInputLabel,
|
||||
@@ -27,6 +24,7 @@ import { AgentSkillImportError, useAgentSkillImport } from '../composables/useAg
|
||||
import type { AgentSkill, AgentSkillReference } from '../types';
|
||||
import { formatToolNameForDisplay } from '../utils/toolDisplayName';
|
||||
import AgentChipButton from './AgentChipButton.vue';
|
||||
import AgentModal from './modals/AgentModal.vue';
|
||||
|
||||
const SKILL_FILE = 'SKILL.md';
|
||||
|
||||
@@ -51,6 +49,7 @@ const props = withDefaults(
|
||||
selectedPath?: string;
|
||||
scrollable?: boolean;
|
||||
showValidationWarnings?: boolean;
|
||||
showNameField?: boolean;
|
||||
}>(),
|
||||
{
|
||||
availableTools: () => [],
|
||||
@@ -59,6 +58,7 @@ const props = withDefaults(
|
||||
selectedPath: SKILL_FILE,
|
||||
scrollable: true,
|
||||
showValidationWarnings: false,
|
||||
showNameField: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -86,7 +86,6 @@ const referenceFileName = ref('');
|
||||
const fileError = ref('');
|
||||
const addToolDialogOpen = ref(false);
|
||||
const formValidation = reactive({
|
||||
name: false,
|
||||
description: false,
|
||||
referenceName: true,
|
||||
});
|
||||
@@ -98,6 +97,14 @@ const nameValidationRules: Array<Rule | RuleGroup> = [
|
||||
const normalizedExistingSkillNames = computed(
|
||||
() => new Set(props.existingSkillNames.map((name) => name.trim().toLowerCase())),
|
||||
);
|
||||
const nameIsValid = computed(() => {
|
||||
const value = name.value.trim();
|
||||
return (
|
||||
value.length > 0 &&
|
||||
value.length <= 128 &&
|
||||
!normalizedExistingSkillNames.value.has(value.toLowerCase())
|
||||
);
|
||||
});
|
||||
const nameValidators: Record<string, IValidator> = {
|
||||
uniqueSkillName: {
|
||||
validate: (value: Validatable) =>
|
||||
@@ -167,7 +174,7 @@ const referencesValid = computed(
|
||||
);
|
||||
const formIsValid = computed(
|
||||
() =>
|
||||
formValidation.name &&
|
||||
nameIsValid.value &&
|
||||
formValidation.description &&
|
||||
(!selectedReference.value || formValidation.referenceName) &&
|
||||
instructionsValid.value &&
|
||||
@@ -245,8 +252,8 @@ function onDescriptionInput(value: string | number | boolean | null | undefined)
|
||||
emit('update:skill', { description: next });
|
||||
}
|
||||
|
||||
function onFieldValidate(field: 'name' | 'description', valid: boolean) {
|
||||
formValidation[field] = valid;
|
||||
function onDescriptionValidate(valid: boolean) {
|
||||
formValidation.description = valid;
|
||||
}
|
||||
|
||||
function onReferenceNameValidate(valid: boolean) {
|
||||
@@ -448,6 +455,7 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
|
||||
ref="skillFileInput"
|
||||
type="file"
|
||||
accept=".md,text/markdown"
|
||||
tabindex="-1"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.fileInput"
|
||||
data-testid="agent-skill-skill-md-file-input"
|
||||
@@ -458,6 +466,7 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
|
||||
type="file"
|
||||
webkitdirectory
|
||||
multiple
|
||||
tabindex="-1"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.fileInput"
|
||||
data-testid="agent-skill-folder-file-input"
|
||||
@@ -470,7 +479,7 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
|
||||
props.errors.references
|
||||
}}</N8nText>
|
||||
|
||||
<div :class="$style.field">
|
||||
<div v-if="props.showNameField" :class="$style.field">
|
||||
<N8nFormInput
|
||||
:model-value="name"
|
||||
:label="i18n.baseText('agents.builder.skills.name.label')"
|
||||
@@ -484,7 +493,6 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
|
||||
:validators="nameValidators"
|
||||
data-testid="agent-skill-name-input"
|
||||
@update:model-value="onNameInput"
|
||||
@validate="onFieldValidate('name', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -494,6 +502,7 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
|
||||
:label="i18n.baseText('agents.builder.skills.description.label')"
|
||||
name="skill-description"
|
||||
required
|
||||
:focus-initially="!props.showNameField"
|
||||
label-size="small"
|
||||
:placeholder="i18n.baseText('agents.builder.skills.description.placeholder')"
|
||||
:disabled="props.disabled"
|
||||
@@ -501,7 +510,7 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
|
||||
:validation-rules="descriptionValidationRules"
|
||||
data-testid="agent-skill-description-input"
|
||||
@update:model-value="onDescriptionInput"
|
||||
@validate="onFieldValidate('description', $event)"
|
||||
@validate="onDescriptionValidate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -638,12 +647,14 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
|
||||
</N8nInputLabel>
|
||||
</div>
|
||||
|
||||
<N8nDialog :open="addToolDialogOpen" size="small" @update:open="addToolDialogOpen = $event">
|
||||
<N8nDialogHeader>
|
||||
<N8nDialogTitle>
|
||||
{{ i18n.baseText('agents.builder.skills.allowedTools.addModal.title' as BaseTextKey) }}
|
||||
</N8nDialogTitle>
|
||||
</N8nDialogHeader>
|
||||
<AgentModal
|
||||
:open="addToolDialogOpen"
|
||||
:title="i18n.baseText('agents.builder.skills.allowedTools.addModal.title' as BaseTextKey)"
|
||||
size="small"
|
||||
stacked
|
||||
:show-footer="false"
|
||||
@update:open="addToolDialogOpen = $event"
|
||||
>
|
||||
<div :class="$style.allowedToolOptions">
|
||||
<N8nText v-if="props.availableTools.length === 0" size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.skills.allowedTools.addModal.empty' as BaseTextKey) }}
|
||||
@@ -664,7 +675,7 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
|
||||
{{ tool.label }}
|
||||
</AgentChipButton>
|
||||
</div>
|
||||
</N8nDialog>
|
||||
</AgentModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -749,7 +760,6 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing--2xs);
|
||||
margin-top: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.allowedToolOption {
|
||||
|
||||
+153
-261
@@ -1,26 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import {
|
||||
N8nEmptyState,
|
||||
N8nButton,
|
||||
N8nCallout,
|
||||
N8nIcon,
|
||||
N8nIconButton,
|
||||
N8nHeading,
|
||||
N8nInput,
|
||||
N8nMarkdownEditor,
|
||||
N8nScrollArea,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { SUB_AGENT_USE_WHEN_MAX_LENGTH } from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
|
||||
import Modal from '@/app/components/Modal.vue';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import ToolsConnectionModal from '@/features/shared/toolsConnection/ToolsConnectionModal.vue';
|
||||
import type {
|
||||
AgentConnectionItem,
|
||||
ToolConnectionItem,
|
||||
} from '@/features/shared/toolsConnection/types';
|
||||
import AgentModalMultiStep from './modals/AgentModalMultiStep.vue';
|
||||
|
||||
export type AgentSubAgentOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
added?: boolean;
|
||||
useWhen?: string;
|
||||
invalidReasons?: string[];
|
||||
agentHref?: string;
|
||||
};
|
||||
|
||||
type AgentSubAgentsModalConfirmPayload = { agentId: string; useWhen?: string };
|
||||
@@ -28,8 +33,9 @@ type AgentSubAgentsModalConfirmPayload = { agentId: string; useWhen?: string };
|
||||
type AddSubAgentModalData = {
|
||||
agents: AgentSubAgentOption[];
|
||||
selectedAgent?: never;
|
||||
onCreateAgent?: () => void;
|
||||
onConfirm: (payload: AgentSubAgentsModalConfirmPayload) => void;
|
||||
onRemove?: never;
|
||||
onRemove?: (agentId: string) => void;
|
||||
};
|
||||
|
||||
type EditSubAgentModalData = {
|
||||
@@ -51,24 +57,30 @@ const props = defineProps<{
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
|
||||
|
||||
const availableAgents = computed(() => ('agents' in props.data ? props.data.agents : []));
|
||||
const hasAgents = computed(() => availableAgents.value.length > 0);
|
||||
const searchQuery = ref('');
|
||||
const normalizedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase());
|
||||
const filteredAgents = computed(() =>
|
||||
normalizedSearchQuery.value
|
||||
? availableAgents.value.filter((agent) =>
|
||||
agent.name.toLowerCase().includes(normalizedSearchQuery.value),
|
||||
)
|
||||
: availableAgents.value,
|
||||
const pickerItems = computed<AgentConnectionItem[]>(() =>
|
||||
availableAgents.value.map((agent) => ({
|
||||
id: `agent:${agent.id}`,
|
||||
kind: 'agent',
|
||||
agentId: agent.id,
|
||||
title: agent.name,
|
||||
status: agent.added ? 'connected' : 'none',
|
||||
category: 'agents',
|
||||
})),
|
||||
);
|
||||
const hasMatchingAgents = computed(() => filteredAgents.value.length > 0);
|
||||
const isEditing = computed(() => Boolean(props.data.selectedAgent));
|
||||
const invalidReasons = computed(() =>
|
||||
'invalidReasons' in props.data ? (props.data.invalidReasons ?? []) : [],
|
||||
);
|
||||
const selectedAgent = ref<AgentSubAgentOption | null>(props.data.selectedAgent ?? null);
|
||||
const selectedAgentIsAdded = computed(() => isEditing.value || Boolean(selectedAgent.value?.added));
|
||||
const invalidReasons = computed(() => {
|
||||
if ('invalidReasons' in props.data) return props.data.invalidReasons ?? [];
|
||||
return selectedAgent.value?.invalidReasons ?? [];
|
||||
});
|
||||
const selectedAgentHref = computed(() => {
|
||||
if ('agentHref' in props.data) return props.data.agentHref;
|
||||
return selectedAgent.value?.agentHref;
|
||||
});
|
||||
const useWhen = ref(('useWhen' in props.data ? props.data.useWhen : '') ?? '');
|
||||
const useWhenTrimmed = computed(() => useWhen.value.trim());
|
||||
const useWhenError = computed(() => {
|
||||
@@ -78,6 +90,10 @@ const useWhenError = computed(() => {
|
||||
});
|
||||
});
|
||||
const canConfirm = computed(() => !useWhenError.value);
|
||||
const currentStep = computed(() => (selectedAgent.value ? 'configure' : 'select'));
|
||||
const title = computed(
|
||||
() => selectedAgent.value?.name ?? i18n.baseText('agents.builder.subAgents.modal.title'),
|
||||
);
|
||||
|
||||
function closeModal() {
|
||||
uiStore.closeModal(props.modalName);
|
||||
@@ -85,7 +101,19 @@ function closeModal() {
|
||||
|
||||
function onSelectAgent(agent: AgentSubAgentOption) {
|
||||
selectedAgent.value = agent;
|
||||
useWhen.value = '';
|
||||
useWhen.value = agent.useWhen ?? '';
|
||||
}
|
||||
|
||||
function onPickerItemActivate(item: ToolConnectionItem) {
|
||||
if (item.kind !== 'agent') return;
|
||||
const agent = availableAgents.value.find((candidate) => candidate.id === item.agentId);
|
||||
if (agent) onSelectAgent(agent);
|
||||
}
|
||||
|
||||
function onCreateAgent() {
|
||||
if (!('onCreateAgent' in props.data) || !props.data.onCreateAgent) return;
|
||||
closeModal();
|
||||
props.data.onCreateAgent();
|
||||
}
|
||||
|
||||
function onBack() {
|
||||
@@ -112,258 +140,133 @@ function onConfirm() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:name="props.modalName"
|
||||
width="640px"
|
||||
:custom-class="$style.modal"
|
||||
<AgentModalMultiStep
|
||||
:open="modalOpen"
|
||||
:step="currentStep"
|
||||
:title="title"
|
||||
:show-back="Boolean(selectedAgent) && !isEditing"
|
||||
:show-footer="Boolean(selectedAgent)"
|
||||
data-testid="agent-sub-agents-modal"
|
||||
@update:open="!$event && closeModal()"
|
||||
@back="onBack"
|
||||
>
|
||||
<template #header>
|
||||
<div :class="$style.header">
|
||||
<N8nHeading tag="h2" size="large">
|
||||
{{
|
||||
selectedAgent
|
||||
? selectedAgent.name
|
||||
: i18n.baseText('agents.builder.subAgents.modal.title')
|
||||
}}
|
||||
</N8nHeading>
|
||||
<N8nIconButton
|
||||
v-if="selectedAgent && 'agentHref' in data && data.agentHref"
|
||||
icon="external-link"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
:href="data.agentHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="i18n.baseText('agents.builder.subAgents.open')"
|
||||
:aria-label="i18n.baseText('agents.builder.subAgents.open')"
|
||||
data-test-id="agent-sub-agents-modal-open"
|
||||
/>
|
||||
</div>
|
||||
<template #headerActions>
|
||||
<N8nIconButton
|
||||
v-if="selectedAgent && selectedAgentHref"
|
||||
icon="external-link"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
:href="selectedAgentHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="i18n.baseText('agents.builder.subAgents.open')"
|
||||
:aria-label="i18n.baseText('agents.builder.subAgents.open')"
|
||||
data-testid="agent-sub-agents-modal-open"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="!selectedAgent" :class="$style.content">
|
||||
<ToolsConnectionModal
|
||||
v-show="!selectedAgent"
|
||||
:open="modalOpen"
|
||||
:items="pickerItems"
|
||||
:categories="['agents']"
|
||||
:detail-item="null"
|
||||
:search-placeholder="i18n.baseText('agents.builder.subAgents.modal.search.placeholder')"
|
||||
:empty-message="i18n.baseText('agents.builder.subAgents.modal.empty.title')"
|
||||
:no-results-message="i18n.baseText('agents.builder.subAgents.modal.noResults.title')"
|
||||
:create-action="
|
||||
'onCreateAgent' in data && data.onCreateAgent
|
||||
? {
|
||||
category: 'agents',
|
||||
label: i18n.baseText('projects.header.create.agent'),
|
||||
description: i18n.baseText('projectRoles.agent:create.tooltip'),
|
||||
testId: 'agent-sub-agents-modal-create',
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:connect-label="() => i18n.baseText('agents.builder.subAgents.modal.add')"
|
||||
:connect-aria-label="
|
||||
(item) =>
|
||||
i18n.baseText('agents.builder.subAgents.modal.addAriaLabel' as BaseTextKey, {
|
||||
interpolate: { name: item.title },
|
||||
})
|
||||
"
|
||||
:connected-label="() => i18n.baseText('agents.builder.subAgents.modal.added' as BaseTextKey)"
|
||||
embedded
|
||||
show-connect-actions
|
||||
persistent-scrollbar
|
||||
@connect="onPickerItemActivate"
|
||||
@open-detail="onPickerItemActivate"
|
||||
@create="onCreateAgent"
|
||||
/>
|
||||
|
||||
<div v-if="selectedAgent" :class="[$style.content, $style.configureContent]">
|
||||
<N8nCallout
|
||||
v-if="invalidReasons.length > 0"
|
||||
theme="danger"
|
||||
data-testid="agent-sub-agents-modal-invalid-callout"
|
||||
>
|
||||
<div v-for="reason in invalidReasons" :key="reason">{{ reason }}</div>
|
||||
</N8nCallout>
|
||||
<div :class="$style.field">
|
||||
<label :class="$style.label">
|
||||
<N8nText size="small" :bold="true">
|
||||
{{ i18n.baseText('agents.builder.subAgents.useWhen.label') }}
|
||||
</N8nText>
|
||||
</label>
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.subAgents.modal.description') }}
|
||||
{{ i18n.baseText('agents.builder.subAgents.useWhen.hint') }}
|
||||
</N8nText>
|
||||
|
||||
<N8nInput
|
||||
v-if="hasAgents"
|
||||
v-model="searchQuery"
|
||||
:placeholder="i18n.baseText('agents.builder.subAgents.modal.search.placeholder')"
|
||||
clearable
|
||||
data-testid="agent-sub-agents-modal-search"
|
||||
>
|
||||
<template #prefix>
|
||||
<N8nIcon icon="search" :size="16" />
|
||||
</template>
|
||||
</N8nInput>
|
||||
|
||||
<N8nScrollArea v-if="hasAgents && hasMatchingAgents" max-height="420px" type="auto">
|
||||
<div :class="$style.rows">
|
||||
<div
|
||||
v-for="agent in filteredAgents"
|
||||
:key="agent.id"
|
||||
:class="$style.row"
|
||||
data-testid="agent-sub-agents-modal-row"
|
||||
>
|
||||
<div :class="$style.iconWrapper">
|
||||
<N8nIcon icon="bot" :size="24" :class="$style.itemIcon" />
|
||||
</div>
|
||||
|
||||
<div :class="$style.rowBody">
|
||||
<N8nText size="small" color="text-dark" :class="$style.name">
|
||||
{{ agent.name }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div :class="$style.actions">
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
size="small"
|
||||
data-testid="agent-sub-agents-modal-add"
|
||||
@click="onSelectAgent(agent)"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.subAgents.modal.add') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</N8nScrollArea>
|
||||
|
||||
<N8nEmptyState
|
||||
v-else-if="hasAgents && !hasMatchingAgents"
|
||||
:icon="{ type: 'icon', value: 'bot' }"
|
||||
:heading="i18n.baseText('agents.builder.subAgents.modal.noResults.title')"
|
||||
:description="i18n.baseText('agents.builder.subAgents.modal.noResults.description')"
|
||||
data-testid="agent-sub-agents-modal-no-results"
|
||||
/>
|
||||
|
||||
<N8nEmptyState
|
||||
v-else
|
||||
:icon="{ type: 'icon', value: 'bot' }"
|
||||
:heading="i18n.baseText('agents.builder.subAgents.modal.empty.title')"
|
||||
:description="i18n.baseText('agents.builder.subAgents.modal.empty.description')"
|
||||
data-testid="agent-sub-agents-modal-empty"
|
||||
<N8nMarkdownEditor
|
||||
:class="$style.useWhenEditor"
|
||||
:model-value="useWhen"
|
||||
:placeholder="i18n.baseText('agents.builder.subAgents.useWhen.placeholder')"
|
||||
show-toolbar="floating"
|
||||
max-height="100%"
|
||||
data-testid="agent-sub-agents-modal-use-when"
|
||||
@update:model-value="useWhen = $event"
|
||||
/>
|
||||
<N8nText v-if="useWhenError" size="small" color="danger">
|
||||
{{ useWhenError }}
|
||||
</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{
|
||||
i18n.baseText('agents.builder.subAgents.useWhen.characterCount', {
|
||||
interpolate: {
|
||||
count: String(useWhen.length),
|
||||
max: String(SUB_AGENT_USE_WHEN_MAX_LENGTH),
|
||||
},
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else :class="[$style.content, $style.configureContent]">
|
||||
<N8nCallout
|
||||
v-if="invalidReasons.length > 0"
|
||||
theme="danger"
|
||||
data-testid="agent-sub-agents-modal-invalid-callout"
|
||||
>
|
||||
<div v-for="reason in invalidReasons" :key="reason">{{ reason }}</div>
|
||||
</N8nCallout>
|
||||
<div :class="$style.field">
|
||||
<label :class="$style.label">
|
||||
<N8nText size="small" :bold="true">
|
||||
{{ i18n.baseText('agents.builder.subAgents.useWhen.label') }}
|
||||
</N8nText>
|
||||
</label>
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.subAgents.useWhen.hint') }}
|
||||
</N8nText>
|
||||
<N8nMarkdownEditor
|
||||
:class="$style.useWhenEditor"
|
||||
:model-value="useWhen"
|
||||
:placeholder="i18n.baseText('agents.builder.subAgents.useWhen.placeholder')"
|
||||
show-toolbar="floating"
|
||||
max-height="100%"
|
||||
data-testid="agent-sub-agents-modal-use-when"
|
||||
@update:model-value="useWhen = $event"
|
||||
/>
|
||||
<N8nText v-if="useWhenError" size="small" color="danger">
|
||||
{{ useWhenError }}
|
||||
</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{
|
||||
i18n.baseText('agents.builder.subAgents.useWhen.characterCount', {
|
||||
interpolate: {
|
||||
count: String(useWhen.length),
|
||||
max: String(SUB_AGENT_USE_WHEN_MAX_LENGTH),
|
||||
},
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="selectedAgent && selectedAgentIsAdded && data.onRemove" #footerLeft>
|
||||
<N8nButton variant="ghost" data-testid="agent-sub-agents-modal-remove" @click="onRemove">
|
||||
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
|
||||
{{ i18n.baseText('agents.builder.subAgents.modal.remove') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
|
||||
<template v-if="selectedAgent" #footer>
|
||||
<div :class="$style.footer">
|
||||
<N8nButton
|
||||
v-if="isEditing && data.onRemove"
|
||||
variant="subtle"
|
||||
data-testid="agent-sub-agents-modal-remove"
|
||||
@click="onRemove"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.subAgents.modal.remove') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
v-else
|
||||
variant="subtle"
|
||||
data-testid="agent-sub-agents-modal-back"
|
||||
@click="onBack"
|
||||
>
|
||||
{{ i18n.baseText('generic.back') }}
|
||||
</N8nButton>
|
||||
<div :class="$style.footerActions">
|
||||
<N8nButton variant="subtle" @click="closeModal">
|
||||
{{ i18n.baseText('generic.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
:disabled="!canConfirm"
|
||||
data-testid="agent-sub-agents-modal-confirm"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ i18n.baseText(isEditing ? 'generic.save' : 'agents.builder.subAgents.modal.add') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="selectedAgent" #footerActions>
|
||||
<N8nButton variant="solid" data-testid="agent-sub-agents-modal-confirm" @click="onConfirm">
|
||||
{{ i18n.baseText('generic.save') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</AgentModalMultiStep>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.modal {
|
||||
:global(.modal-content) {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--md);
|
||||
margin: calc(-1 * var(--spacing--lg));
|
||||
padding: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.configureContent {
|
||||
min-height: 460px;
|
||||
gap: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-right: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--sm);
|
||||
flex-shrink: 0;
|
||||
padding-block: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rowBody {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: var(--line-height--md);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
color: var(--text-color--subtle);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -377,17 +280,6 @@ function onConfirm() {
|
||||
}
|
||||
|
||||
.useWhenEditor {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
height: min(40dvh, calc(var(--height--5xl) * 3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import {
|
||||
N8nButton,
|
||||
N8nFormInput,
|
||||
N8nHeading,
|
||||
N8nIcon,
|
||||
N8nInput,
|
||||
N8nMarkdownEditor,
|
||||
@@ -25,11 +24,8 @@ import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import Modal from '@/app/components/Modal.vue';
|
||||
import { MODAL_CONFIRM } from '@/app/constants';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { createAgentTask, deleteAgentTask, updateAgentTask } from '../composables/useAgentApi';
|
||||
import { useAgentConfirmationModal } from '../composables/useAgentConfirmationModal';
|
||||
import {
|
||||
buildCron,
|
||||
DEFAULT_SCHEDULE_PARTS,
|
||||
@@ -42,6 +38,7 @@ import {
|
||||
weekdayLabel,
|
||||
} from '../utils/scheduleBuilder';
|
||||
import AgentPreviewButton from './AgentPreviewButton.vue';
|
||||
import AgentModal from './modals/AgentModal.vue';
|
||||
|
||||
export type AgentTaskModalData = {
|
||||
projectId: string;
|
||||
@@ -70,7 +67,7 @@ const i18n = useI18n();
|
||||
const rootStore = useRootStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const uiStore = useUIStore();
|
||||
const { openAgentConfirmationModal } = useAgentConfirmationModal();
|
||||
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
|
||||
|
||||
const task = computed(() => props.data.task ?? null);
|
||||
const isEditing = computed(() => Boolean(task.value));
|
||||
@@ -101,7 +98,6 @@ const errorMessage = ref('');
|
||||
// Save is always clickable; clicking with invalid data reveals every field's
|
||||
// error instead of silently no-op'ing behind a disabled button.
|
||||
const saveAttempted = ref(false);
|
||||
const nameValid = ref(false);
|
||||
const objectiveTouched = ref(false);
|
||||
// Non-custom frequencies always build a well-formed cron (see `cronExpression`
|
||||
// below), so only the custom field's own validator can make this false.
|
||||
@@ -132,7 +128,7 @@ const cronExpression = computed(() => {
|
||||
|
||||
function applyTask() {
|
||||
const current = task.value;
|
||||
name.value = current?.name ?? '';
|
||||
name.value = current?.name ?? i18n.baseText('agents.builder.tasks.defaultName' as BaseTextKey);
|
||||
objective.value = current?.objective ?? '';
|
||||
// A task saved before schedules carried their own timezone runs in the
|
||||
// instance timezone, so keep showing that instead of the viewer's zone.
|
||||
@@ -159,7 +155,6 @@ applyTask();
|
||||
// Snapshot of whether each field was already invalid when an existing task
|
||||
// opened, so its error shows immediately instead of only after a touch/save.
|
||||
// A pristine new-task field stays quiet until the user interacts with it.
|
||||
const initialNameInvalid = isEditing.value && !name.value.trim();
|
||||
const initialObjectiveInvalid = isEditing.value && !objective.value.trim();
|
||||
const initialCronInvalid =
|
||||
isEditing.value && !getNextScheduleOccurrence(cronExpression.value, timezone.value);
|
||||
@@ -310,8 +305,21 @@ const visibleObjectiveError = computed(() =>
|
||||
: '',
|
||||
);
|
||||
|
||||
const nameError = computed(() => {
|
||||
if (!name.value.trim()) {
|
||||
return i18n.baseText('agents.builder.tasks.validation.nameRequired');
|
||||
}
|
||||
if (name.value.trim().length > AGENT_TASK_NAME_MAX_LENGTH) {
|
||||
return i18n.baseText('agents.builder.tasks.validation.nameMaxLength' as BaseTextKey, {
|
||||
interpolate: { max: String(AGENT_TASK_NAME_MAX_LENGTH) },
|
||||
});
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const visibleNameError = computed(() => (saveAttempted.value ? nameError.value : ''));
|
||||
|
||||
const canSave = computed(
|
||||
() => nameValid.value && !objectiveError.value && cronValid.value && !saving.value,
|
||||
() => !nameError.value && !objectiveError.value && cronValid.value && !saving.value,
|
||||
);
|
||||
|
||||
function onObjectiveInput(value: string) {
|
||||
@@ -319,10 +327,6 @@ function onObjectiveInput(value: string) {
|
||||
objectiveTouched.value = true;
|
||||
}
|
||||
|
||||
function onNameInput(value: Validatable) {
|
||||
name.value = typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function onCronInput(value: Validatable) {
|
||||
customCron.value = typeof value === 'string' ? value : '';
|
||||
scheduleTouched.value = true;
|
||||
@@ -360,13 +364,6 @@ function onPreview() {
|
||||
async function onDelete() {
|
||||
const current = task.value;
|
||||
if (!current || deleting.value) return;
|
||||
const confirmed = await openAgentConfirmationModal({
|
||||
title: i18n.baseText('agents.builder.tasks.deleteConfirm.title'),
|
||||
description: i18n.baseText('agents.builder.tasks.deleteConfirm.description'),
|
||||
confirmButtonText: i18n.baseText('agents.builder.tasks.deleteConfirm.confirm'),
|
||||
cancelButtonText: i18n.baseText('agents.builder.tasks.cancel'),
|
||||
});
|
||||
if (confirmed !== MODAL_CONFIRM) return;
|
||||
|
||||
deleting.value = true;
|
||||
errorMessage.value = '';
|
||||
@@ -383,7 +380,7 @@ async function onDelete() {
|
||||
errorMessage.value =
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: i18n.baseText('agents.builder.tasks.deleteError');
|
||||
: i18n.baseText('agents.builder.tasks.removeError' as BaseTextKey);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
@@ -413,7 +410,7 @@ async function onSave() {
|
||||
base,
|
||||
);
|
||||
} else {
|
||||
/** Save the agent only on submit, so canceling does not create an empty agent. */
|
||||
/** Save the agent only on submit, so closing does not create an empty agent. */
|
||||
await props.data.ensureAgentPersisted?.();
|
||||
/** New tasks start running once the agent is published. */
|
||||
await createAgentTask(rootStore.restApiContext, props.data.projectId, props.data.agentId, {
|
||||
@@ -435,265 +432,236 @@ async function onSave() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :name="modalName" width="860px" data-testid="agent-task-modal">
|
||||
<template #header>
|
||||
<div :class="$style.header">
|
||||
<N8nHeading tag="h2" size="large">
|
||||
{{
|
||||
i18n.baseText(
|
||||
isEditing ? 'agents.builder.tasks.edit.title' : 'agents.builder.tasks.create.title',
|
||||
)
|
||||
}}
|
||||
</N8nHeading>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div :class="$style.content">
|
||||
<div :class="$style.field">
|
||||
<N8nFormInput
|
||||
:model-value="name"
|
||||
:label="i18n.baseText('agents.builder.tasks.name.label')"
|
||||
name="task-name"
|
||||
required
|
||||
label-size="small"
|
||||
:placeholder="i18n.baseText('agents.builder.tasks.name.placeholder')"
|
||||
:validation-rules="[
|
||||
{ name: 'MAX_LENGTH', config: { maximum: AGENT_TASK_NAME_MAX_LENGTH } },
|
||||
]"
|
||||
:show-validation-warnings="saveAttempted || initialNameInvalid"
|
||||
data-testid="agent-task-name-input"
|
||||
@update:model-value="onNameInput"
|
||||
@validate="nameValid = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.field">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.builder.tasks.objective.label') }}
|
||||
<N8nText color="primary" bold size="small">*</N8nText>
|
||||
</N8nText>
|
||||
<N8nMarkdownEditor
|
||||
:class="$style.objectiveEditor"
|
||||
:model-value="objective"
|
||||
:placeholder="i18n.baseText('agents.builder.tasks.objective.placeholder')"
|
||||
show-toolbar="floating"
|
||||
max-height="100%"
|
||||
data-testid="agent-task-objective-input"
|
||||
@update:model-value="onObjectiveInput"
|
||||
/>
|
||||
<N8nText v-if="visibleObjectiveError" :class="$style.error" size="small">
|
||||
{{ visibleObjectiveError }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div :class="$style.field">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.builder.tasks.schedule.label') }}
|
||||
<N8nText color="primary" bold size="small">*</N8nText>
|
||||
</N8nText>
|
||||
<div :class="$style.scheduleRow">
|
||||
<N8nSelect
|
||||
:model-value="frequency"
|
||||
:class="$style.frequencySelect"
|
||||
data-testid="agent-task-frequency"
|
||||
@update:model-value="onFrequencyChange"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="option in frequencyOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
|
||||
<template v-if="frequency === 'weekly'">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.tasks.schedule.on') }}
|
||||
</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="dayOfWeek"
|
||||
:class="$style.daySelect"
|
||||
data-testid="agent-task-day-of-week"
|
||||
@update:model-value="onDayOfWeekChange"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="day in dayOfWeekOptions"
|
||||
:key="day.value"
|
||||
:value="day.value"
|
||||
:label="day.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</template>
|
||||
|
||||
<template v-if="frequency === 'monthly'">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.tasks.schedule.onDay') }}
|
||||
</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="dayOfMonth"
|
||||
:class="$style.daySelect"
|
||||
data-testid="agent-task-day-of-month"
|
||||
@update:model-value="onDayOfMonthChange"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="day in dayOfMonthOptions"
|
||||
:key="day.value"
|
||||
:value="day.value"
|
||||
:label="day.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</template>
|
||||
|
||||
<template v-if="showTime">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.tasks.schedule.at') }}
|
||||
</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="selectedTime"
|
||||
:class="$style.timeSelect"
|
||||
data-testid="agent-task-time"
|
||||
@update:model-value="selectedTime = Number($event)"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="option in timeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</template>
|
||||
|
||||
<template v-if="frequency === 'hourly'">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.tasks.schedule.minuteLabel') }}
|
||||
</N8nText>
|
||||
<N8nInput
|
||||
type="number"
|
||||
:model-value="String(minute)"
|
||||
:class="$style.minuteInput"
|
||||
data-testid="agent-task-minute"
|
||||
@update:model-value="onMinuteInput"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<N8nFormInput
|
||||
v-if="frequency === 'custom'"
|
||||
:model-value="customCron"
|
||||
label=""
|
||||
name="task-cron"
|
||||
required
|
||||
:placeholder="i18n.baseText('agents.builder.tasks.schedule.cron.placeholder')"
|
||||
:validators="{ VALID_CRON: cronValidator }"
|
||||
:validation-rules="[{ name: 'VALID_CRON' }]"
|
||||
:show-validation-warnings="saveAttempted || initialCronInvalid"
|
||||
:class="$style.cronInput"
|
||||
data-testid="agent-task-schedule-cron"
|
||||
@update:model-value="onCronInput"
|
||||
@validate="cronValid = $event"
|
||||
/>
|
||||
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.tasks.schedule.in') }}
|
||||
</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="timezone"
|
||||
:class="$style.timezoneSelect"
|
||||
:placeholder="i18n.baseText('agents.builder.tasks.schedule.timezone.placeholder')"
|
||||
filterable
|
||||
:limit-popper-width="true"
|
||||
data-testid="agent-task-timezone"
|
||||
@update:model-value="onTimezoneChange"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="option in timezoneSelectOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
<div v-if="scheduleSummary" :class="$style.scheduleSummary">
|
||||
<N8nText :class="$style.help" size="small">
|
||||
{{ scheduleSummary }}
|
||||
</N8nText>
|
||||
<N8nTooltip
|
||||
v-if="showRepublishHint"
|
||||
:content="i18n.baseText('agents.builder.tasks.republishHint')"
|
||||
placement="top"
|
||||
>
|
||||
<span
|
||||
:class="$style.infoIcon"
|
||||
:aria-label="i18n.baseText('agents.builder.tasks.republishHint')"
|
||||
tabindex="0"
|
||||
>
|
||||
<N8nIcon icon="info" size="small" />
|
||||
</span>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing" :class="$style.pauseControl" data-testid="agent-task-pause-control">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.builder.tasks.pause') }}
|
||||
</N8nText>
|
||||
<N8nSwitch2
|
||||
:model-value="!enabled"
|
||||
:aria-label="i18n.baseText('agents.builder.tasks.pause')"
|
||||
data-testid="agent-task-toggle"
|
||||
@update:model-value="(paused) => onPauseToggle(Boolean(paused))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<N8nText v-if="errorMessage" :class="$style.error" size="small">
|
||||
{{ errorMessage }}
|
||||
<AgentModal
|
||||
:open="modalOpen"
|
||||
:title="name"
|
||||
:title-placeholder="i18n.baseText('agents.builder.tasks.name.placeholder')"
|
||||
:title-max-length="AGENT_TASK_NAME_MAX_LENGTH"
|
||||
:title-error="visibleNameError"
|
||||
:busy="saving || deleting"
|
||||
editable-title
|
||||
data-testid="agent-task-modal"
|
||||
@update:open="!$event && closeModal()"
|
||||
@update:title="name = $event"
|
||||
>
|
||||
<div :class="$style.content">
|
||||
<div :class="$style.field">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.builder.tasks.objective.label') }}
|
||||
<N8nText color="primary" bold size="small">*</N8nText>
|
||||
</N8nText>
|
||||
<N8nMarkdownEditor
|
||||
:class="$style.objectiveEditor"
|
||||
:model-value="objective"
|
||||
:placeholder="i18n.baseText('agents.builder.tasks.objective.placeholder')"
|
||||
show-toolbar="floating"
|
||||
max-height="100%"
|
||||
data-testid="agent-task-objective-input"
|
||||
@update:model-value="onObjectiveInput"
|
||||
/>
|
||||
<N8nText v-if="visibleObjectiveError" :class="$style.error" size="small">
|
||||
{{ visibleObjectiveError }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<N8nButton
|
||||
v-if="isEditing"
|
||||
variant="subtle"
|
||||
:loading="deleting"
|
||||
data-testid="agent-task-delete"
|
||||
@click="onDelete"
|
||||
>
|
||||
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
|
||||
{{ i18n.baseText('generic.delete') }}
|
||||
</N8nButton>
|
||||
<div :class="$style.footerActions">
|
||||
<AgentPreviewButton
|
||||
:is-runnable="props.data.isRunnable === true"
|
||||
:validation-issues="props.data.validationIssues ?? []"
|
||||
test-id="agent-task-preview"
|
||||
@open-preview="onPreview"
|
||||
/>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
:disabled="saving"
|
||||
:loading="saving"
|
||||
data-testid="agent-task-save"
|
||||
@click="onSave"
|
||||
<div :class="$style.field">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.builder.tasks.schedule.label') }}
|
||||
<N8nText color="primary" bold size="small">*</N8nText>
|
||||
</N8nText>
|
||||
<div :class="$style.scheduleRow">
|
||||
<N8nSelect
|
||||
:model-value="frequency"
|
||||
:class="$style.frequencySelect"
|
||||
data-testid="agent-task-frequency"
|
||||
@update:model-value="onFrequencyChange"
|
||||
>
|
||||
{{ i18n.baseText('generic.save') }}
|
||||
</N8nButton>
|
||||
<N8nOption
|
||||
v-for="option in frequencyOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
|
||||
<template v-if="frequency === 'weekly'">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.tasks.schedule.on') }}
|
||||
</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="dayOfWeek"
|
||||
:class="$style.daySelect"
|
||||
data-testid="agent-task-day-of-week"
|
||||
@update:model-value="onDayOfWeekChange"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="day in dayOfWeekOptions"
|
||||
:key="day.value"
|
||||
:value="day.value"
|
||||
:label="day.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</template>
|
||||
|
||||
<template v-if="frequency === 'monthly'">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.tasks.schedule.onDay') }}
|
||||
</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="dayOfMonth"
|
||||
:class="$style.daySelect"
|
||||
data-testid="agent-task-day-of-month"
|
||||
@update:model-value="onDayOfMonthChange"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="day in dayOfMonthOptions"
|
||||
:key="day.value"
|
||||
:value="day.value"
|
||||
:label="day.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</template>
|
||||
|
||||
<template v-if="showTime">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.tasks.schedule.at') }}
|
||||
</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="selectedTime"
|
||||
:class="$style.timeSelect"
|
||||
data-testid="agent-task-time"
|
||||
@update:model-value="selectedTime = Number($event)"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="option in timeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</template>
|
||||
|
||||
<template v-if="frequency === 'hourly'">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.tasks.schedule.minuteLabel') }}
|
||||
</N8nText>
|
||||
<N8nInput
|
||||
type="number"
|
||||
:model-value="String(minute)"
|
||||
:class="$style.minuteInput"
|
||||
data-testid="agent-task-minute"
|
||||
@update:model-value="onMinuteInput"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<N8nFormInput
|
||||
v-if="frequency === 'custom'"
|
||||
:model-value="customCron"
|
||||
label=""
|
||||
name="task-cron"
|
||||
required
|
||||
:placeholder="i18n.baseText('agents.builder.tasks.schedule.cron.placeholder')"
|
||||
:validators="{ VALID_CRON: cronValidator }"
|
||||
:validation-rules="[{ name: 'VALID_CRON' }]"
|
||||
:show-validation-warnings="saveAttempted || initialCronInvalid"
|
||||
:class="$style.cronInput"
|
||||
data-testid="agent-task-schedule-cron"
|
||||
@update:model-value="onCronInput"
|
||||
@validate="cronValid = $event"
|
||||
/>
|
||||
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.tasks.schedule.in') }}
|
||||
</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="timezone"
|
||||
:class="$style.timezoneSelect"
|
||||
:placeholder="i18n.baseText('agents.builder.tasks.schedule.timezone.placeholder')"
|
||||
filterable
|
||||
:limit-popper-width="true"
|
||||
data-testid="agent-task-timezone"
|
||||
@update:model-value="onTimezoneChange"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="option in timezoneSelectOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
<div v-if="scheduleSummary" :class="$style.scheduleSummary">
|
||||
<N8nText :class="$style.help" size="small">
|
||||
{{ scheduleSummary }}
|
||||
</N8nText>
|
||||
<N8nTooltip
|
||||
v-if="showRepublishHint"
|
||||
:content="i18n.baseText('agents.builder.tasks.republishHint')"
|
||||
placement="top"
|
||||
>
|
||||
<span
|
||||
:class="$style.infoIcon"
|
||||
:aria-label="i18n.baseText('agents.builder.tasks.republishHint')"
|
||||
tabindex="0"
|
||||
>
|
||||
<N8nIcon icon="info" size="small" />
|
||||
</span>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing" :class="$style.pauseControl" data-testid="agent-task-pause-control">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.builder.tasks.pause') }}
|
||||
</N8nText>
|
||||
<N8nSwitch2
|
||||
:model-value="!enabled"
|
||||
:aria-label="i18n.baseText('agents.builder.tasks.pause')"
|
||||
data-testid="agent-task-toggle"
|
||||
@update:model-value="(paused) => onPauseToggle(Boolean(paused))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<N8nText v-if="errorMessage" :class="$style.error" size="small">
|
||||
{{ errorMessage }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<template v-if="isEditing" #footerLeft>
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
:loading="deleting"
|
||||
data-testid="agent-task-delete"
|
||||
@click="onDelete"
|
||||
>
|
||||
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
|
||||
{{ i18n.baseText('agents.builder.tasks.delete') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</Modal>
|
||||
<template #footerBeforeCancel>
|
||||
<AgentPreviewButton
|
||||
:is-runnable="props.data.isRunnable === true"
|
||||
:validation-issues="props.data.validationIssues ?? []"
|
||||
variant="ghost"
|
||||
test-id="agent-task-preview"
|
||||
@open-preview="onPreview"
|
||||
/>
|
||||
</template>
|
||||
<template #footerActions>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
:disabled="saving"
|
||||
:loading="saving"
|
||||
data-testid="agent-task-save"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ i18n.baseText('generic.save') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</AgentModal>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--sm);
|
||||
padding-right: var(--spacing--xl);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -706,9 +674,8 @@ async function onSave() {
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
/* Matches the height the skills instructions editor fills inside its modal. */
|
||||
.objectiveEditor {
|
||||
height: 400px;
|
||||
height: min(36dvh, calc(var(--height--5xl) * 3));
|
||||
}
|
||||
|
||||
.scheduleRow {
|
||||
@@ -768,17 +735,4 @@ async function onSave() {
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
<script setup lang="ts">
|
||||
import type { AgentConfigValidationIssue } from '@n8n/api-types';
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { extractFromAICalls, type INode } from 'n8n-workflow';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
|
||||
import {
|
||||
toolRefToNode,
|
||||
updateToolRefFromNode,
|
||||
updateWorkflowToolRef,
|
||||
} from '../composables/useAgentToolRefAdapter';
|
||||
import { nodeToMcpServer } from '../composables/useMcpServerAdapter';
|
||||
import type {
|
||||
AgentJsonMcpServerConfig,
|
||||
AgentJsonToolRef,
|
||||
CustomToolEntry,
|
||||
WorkflowToolRef,
|
||||
} from '../types';
|
||||
import AgentToolConfigApprovalSetting from './AgentToolConfigApprovalSetting.vue';
|
||||
import AgentToolConfigCustomContent from './AgentToolConfigCustomContent.vue';
|
||||
import AgentToolConfigMcpApprovalSetting from './AgentToolConfigMcpApprovalSetting.vue';
|
||||
import AgentToolConfigNodeContent from './AgentToolConfigNodeContent.vue';
|
||||
import AgentToolConfigWorkflowContent from './AgentToolConfigWorkflowContent.vue';
|
||||
|
||||
interface ToolModalData {
|
||||
toolRef: AgentJsonToolRef;
|
||||
customTool?: CustomToolEntry;
|
||||
existingToolNames?: string[];
|
||||
projectId?: string;
|
||||
agentId?: string;
|
||||
validationIssues?: AgentConfigValidationIssue[];
|
||||
/** Inline agents pass false because approval needs suspend and resume. */
|
||||
supportsToolApproval?: boolean;
|
||||
onConfirm: (updatedRef: AgentJsonToolRef) => void;
|
||||
onRemove?: () => void;
|
||||
kind?: 'tool';
|
||||
}
|
||||
|
||||
interface McpServerModalData {
|
||||
kind: 'mcpServer';
|
||||
mcpServer: AgentJsonMcpServerConfig;
|
||||
initialNode: INode;
|
||||
existingToolNames?: string[];
|
||||
projectId?: string;
|
||||
agentId?: string;
|
||||
/** Inline agents pass false because approval needs suspend and resume. */
|
||||
supportsToolApproval?: boolean;
|
||||
onConfirm: (updatedServer: AgentJsonMcpServerConfig) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export type AgentToolConfigModalData = ToolModalData | McpServerModalData;
|
||||
|
||||
const props = defineProps<{
|
||||
data: AgentToolConfigModalData;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:title': [title: string];
|
||||
'update:credentialModalOpen': [open: boolean];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const httpRequestUrlErrorKey =
|
||||
'agents.builder.validation.issue.httpRequestUrlFromAi' as BaseTextKey;
|
||||
|
||||
const credentialModalOpen = computed(
|
||||
() => uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY]?.open === true,
|
||||
);
|
||||
watch(credentialModalOpen, (open) => emit('update:credentialModalOpen', open), { immediate: true });
|
||||
|
||||
function isMcpServerModalData(data: AgentToolConfigModalData): data is McpServerModalData {
|
||||
return data.kind === 'mcpServer';
|
||||
}
|
||||
|
||||
function containsFromAiCall(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return false;
|
||||
try {
|
||||
return extractFromAICalls(value).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const isMcpTool = computed(() => isMcpServerModalData(props.data));
|
||||
const mcpModalData = computed(() => (isMcpServerModalData(props.data) ? props.data : null));
|
||||
const toolModalData = computed(() => (isMcpServerModalData(props.data) ? null : props.data));
|
||||
const isWorkflowTool = computed(() => toolModalData.value?.toolRef.type === 'workflow');
|
||||
const isCustomTool = computed(() => toolModalData.value?.toolRef.type === 'custom');
|
||||
|
||||
const nodeContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | null>(null);
|
||||
const mcpContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | null>(null);
|
||||
const workflowContentRef = ref<InstanceType<typeof AgentToolConfigWorkflowContent> | null>(null);
|
||||
const isValid = ref(false);
|
||||
const submitted = ref(false);
|
||||
const approvalRequired = ref(false);
|
||||
const mcpApproval = ref<AgentJsonMcpServerConfig['approval']>();
|
||||
const mcpApprovalValid = ref(true);
|
||||
const draftNode = ref<INode | null>(null);
|
||||
|
||||
const initialNode = computed<INode | null>(() =>
|
||||
isMcpTool.value
|
||||
? (mcpModalData.value?.initialNode ?? null)
|
||||
: isWorkflowTool.value || isCustomTool.value
|
||||
? null
|
||||
: toolModalData.value
|
||||
? toolRefToNode(toolModalData.value.toolRef)
|
||||
: null,
|
||||
);
|
||||
|
||||
const workflowInitialRef = computed<WorkflowToolRef | null>(() =>
|
||||
isWorkflowTool.value && toolModalData.value?.toolRef.type === 'workflow'
|
||||
? toolModalData.value.toolRef
|
||||
: null,
|
||||
);
|
||||
|
||||
const initialName = computed(() => {
|
||||
if (isMcpTool.value) return mcpModalData.value?.mcpServer.name ?? '';
|
||||
const toolName =
|
||||
toolModalData.value?.toolRef.type === 'node' ? toolModalData.value.toolRef.name : undefined;
|
||||
return toolName ?? initialNode.value?.name ?? '';
|
||||
});
|
||||
const nodeName = ref(initialName.value);
|
||||
const customToolCode = computed(() =>
|
||||
!isMcpTool.value ? (toolModalData.value?.customTool?.code ?? '') : '',
|
||||
);
|
||||
const customToolTitle = computed(() => {
|
||||
const toolRef = toolModalData.value?.toolRef;
|
||||
const fallbackName =
|
||||
toolRef?.type === 'custom'
|
||||
? toolRef.id
|
||||
: toolRef?.type === 'workflow' || toolRef?.type === 'node'
|
||||
? toolRef.name
|
||||
: undefined;
|
||||
return (
|
||||
toolModalData.value?.customTool?.descriptor.name ??
|
||||
fallbackName ??
|
||||
i18n.baseText('agents.builder.tree.customBadge')
|
||||
);
|
||||
});
|
||||
const title = computed(() => (isCustomTool.value ? customToolTitle.value : nodeName.value));
|
||||
|
||||
const supportsApproval = computed(() => props.data.supportsToolApproval !== false);
|
||||
const showApprovalSetting = computed(
|
||||
() => supportsApproval.value && !isMcpTool.value && toolModalData.value !== null,
|
||||
);
|
||||
|
||||
watch(
|
||||
() => toolModalData.value?.toolRef,
|
||||
(toolRef) => {
|
||||
approvalRequired.value = Boolean(toolRef?.requireApproval);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => mcpModalData.value?.mcpServer.approval,
|
||||
(approval) => {
|
||||
mcpApproval.value = approval;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
initialNode,
|
||||
(node) => {
|
||||
draftNode.value = node;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(title, (value) => emit('update:title', value), { immediate: true });
|
||||
|
||||
const currentNode = computed(() => draftNode.value ?? initialNode.value);
|
||||
const hasHttpRequestUrlIssue = computed(() => {
|
||||
const data = toolModalData.value;
|
||||
if (data?.toolRef.type !== 'node') return false;
|
||||
if (
|
||||
!data.validationIssues?.some(
|
||||
(issue) => issue.code === 'invalid_value' && issue.path.endsWith('.node.nodeParameters.url'),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return containsFromAiCall(currentNode.value?.parameters.url);
|
||||
});
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (isCustomTool.value) return true;
|
||||
if (isMcpTool.value) return isValid.value && mcpApprovalValid.value;
|
||||
return isValid.value && !hasHttpRequestUrlIssue.value;
|
||||
});
|
||||
|
||||
const fromAiDisabledParameters = computed(() => {
|
||||
const toolRef = toolModalData.value?.toolRef;
|
||||
if (
|
||||
toolRef?.type === 'node' &&
|
||||
(toolRef.node.nodeType === HTTP_REQUEST_NODE_TYPE ||
|
||||
toolRef.node.nodeType === HTTP_REQUEST_TOOL_NODE_TYPE)
|
||||
) {
|
||||
return ['url'];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const nodeParameterIssues = computed<Record<string, string[]>>(() => {
|
||||
const issues: Record<string, string[]> = {};
|
||||
if (hasHttpRequestUrlIssue.value) {
|
||||
issues.url = [i18n.baseText(httpRequestUrlErrorKey)];
|
||||
}
|
||||
return issues;
|
||||
});
|
||||
|
||||
function withApprovalRequirement(ref: AgentJsonToolRef): AgentJsonToolRef {
|
||||
if (!supportsApproval.value) {
|
||||
const { requireApproval: _requireApproval, ...rest } = ref;
|
||||
return rest;
|
||||
}
|
||||
const updatedRef = { ...ref };
|
||||
if (approvalRequired.value) updatedRef.requireApproval = true;
|
||||
else delete updatedRef.requireApproval;
|
||||
return updatedRef;
|
||||
}
|
||||
|
||||
function withMcpApproval(server: AgentJsonMcpServerConfig): AgentJsonMcpServerConfig {
|
||||
const updatedServer = { ...server };
|
||||
if (supportsApproval.value && mcpApproval.value) updatedServer.approval = mcpApproval.value;
|
||||
else delete updatedServer.approval;
|
||||
return updatedServer;
|
||||
}
|
||||
|
||||
function confirm(): boolean {
|
||||
submitted.value = true;
|
||||
if (!canSave.value) return false;
|
||||
|
||||
if (isCustomTool.value) {
|
||||
const toolData = toolModalData.value;
|
||||
if (!toolData) return false;
|
||||
toolData.onConfirm(withApprovalRequirement(toolData.toolRef));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isMcpTool.value) {
|
||||
const currentMcpNode = mcpContentRef.value?.getNode();
|
||||
const mcpData = mcpModalData.value;
|
||||
if (!currentMcpNode || !mcpData) return false;
|
||||
const updatedServer = nodeToMcpServer(currentMcpNode, mcpData.mcpServer);
|
||||
mcpData.onConfirm(withMcpApproval(updatedServer));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isWorkflowTool.value) {
|
||||
const content = workflowContentRef.value;
|
||||
const toolData = toolModalData.value;
|
||||
if (!toolData || !content) return false;
|
||||
const workflowId = content.getWorkflowId();
|
||||
const updatedRef = updateWorkflowToolRef(toolData.toolRef, {
|
||||
name: content.getName(),
|
||||
description: content.getDescription(),
|
||||
allOutputs: content.getAllOutputs(),
|
||||
workflow: content.getWorkflow(),
|
||||
...(workflowId !== undefined ? { workflowId } : {}),
|
||||
inputs: content.getInputs(),
|
||||
});
|
||||
toolData.onConfirm(withApprovalRequirement(updatedRef));
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentToolNode = nodeContentRef.value?.getNode();
|
||||
const toolData = toolModalData.value;
|
||||
if (!currentToolNode || !toolData) return false;
|
||||
const updatedRef = updateToolRefFromNode(toolData.toolRef, currentToolNode);
|
||||
toolData.onConfirm(withApprovalRequirement(updatedRef));
|
||||
return true;
|
||||
}
|
||||
|
||||
function remove() {
|
||||
props.data.onRemove?.();
|
||||
}
|
||||
|
||||
function changeTitle(name: string) {
|
||||
if (isCustomTool.value) return;
|
||||
if (isMcpTool.value) mcpContentRef.value?.handleChangeName(name);
|
||||
else if (isWorkflowTool.value) workflowContentRef.value?.handleChangeName(name);
|
||||
else nodeContentRef.value?.handleChangeName(name);
|
||||
}
|
||||
|
||||
function handleNodeNameUpdate(name: string) {
|
||||
nodeName.value = name;
|
||||
}
|
||||
|
||||
defineExpose({ canSave, confirm, remove, changeTitle, credentialModalOpen, title });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.contentWrapper, isCustomTool && $style.codeContentWrapper]">
|
||||
<N8nText
|
||||
v-if="submitted && !canSave"
|
||||
size="small"
|
||||
color="danger"
|
||||
data-testid="agent-tool-config-validation-error"
|
||||
>
|
||||
{{ i18n.baseText('agents.toolConfig.validation.fixFields' as BaseTextKey) }}
|
||||
</N8nText>
|
||||
<AgentToolConfigCustomContent
|
||||
v-if="isCustomTool"
|
||||
:code="customToolCode"
|
||||
:class="$style.customToolViewer"
|
||||
/>
|
||||
<AgentToolConfigApprovalSetting
|
||||
v-if="isCustomTool && showApprovalSetting"
|
||||
v-model="approvalRequired"
|
||||
/>
|
||||
<div v-else :class="$style.configureTab">
|
||||
<AgentToolConfigWorkflowContent
|
||||
v-if="workflowInitialRef"
|
||||
ref="workflowContentRef"
|
||||
:initial-ref="workflowInitialRef"
|
||||
:project-id="data.projectId"
|
||||
:show-approval-setting="showApprovalSetting"
|
||||
:approval-required="approvalRequired"
|
||||
@update:valid="isValid = $event"
|
||||
@update:node-name="handleNodeNameUpdate"
|
||||
@update:approval-required="approvalRequired = $event"
|
||||
/>
|
||||
<AgentToolConfigNodeContent
|
||||
v-else-if="isMcpTool && initialNode"
|
||||
ref="mcpContentRef"
|
||||
:initial-node="initialNode"
|
||||
:existing-tool-names="data.existingToolNames"
|
||||
:project-id="data.projectId"
|
||||
content-test-id="agent-tool-config-mcp-content"
|
||||
@update:valid="isValid = $event"
|
||||
@update:node-name="handleNodeNameUpdate"
|
||||
@update:node="draftNode = $event"
|
||||
/>
|
||||
<AgentToolConfigNodeContent
|
||||
v-else-if="initialNode"
|
||||
ref="nodeContentRef"
|
||||
:initial-node="initialNode"
|
||||
:existing-tool-names="data.existingToolNames"
|
||||
:project-id="data.projectId"
|
||||
:from-ai-disabled-parameters="fromAiDisabledParameters"
|
||||
:parameter-issues="nodeParameterIssues"
|
||||
content-test-id="node-tool-settings-content"
|
||||
@update:valid="isValid = $event"
|
||||
@update:node-name="handleNodeNameUpdate"
|
||||
@update:node="draftNode = $event"
|
||||
/>
|
||||
<AgentToolConfigApprovalSetting
|
||||
v-if="!isMcpTool && initialNode && showApprovalSetting"
|
||||
v-model="approvalRequired"
|
||||
/>
|
||||
<AgentToolConfigMcpApprovalSetting
|
||||
v-if="isMcpTool && currentNode && supportsApproval"
|
||||
v-model="mcpApproval"
|
||||
:node="currentNode"
|
||||
:project-id="data.projectId"
|
||||
@update:valid="mcpApprovalValid = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.contentWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
overflow-x: hidden;
|
||||
|
||||
:global(.ndv-connection-hint-notice) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.codeContentWrapper {
|
||||
height: min(calc(var(--spacing--5xl) * 2), calc(70dvh - var(--spacing--5xl)));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.configureTab {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.customToolViewer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
+69
-508
@@ -1,74 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Configure one agent tool entry (node/workflow/custom) or one MCP server.
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nDialog,
|
||||
N8nDialogFooter,
|
||||
N8nDialogHeader,
|
||||
N8nIcon,
|
||||
} from '@n8n/design-system';
|
||||
import type { AgentConfigValidationIssue } from '@n8n/api-types';
|
||||
import { N8nButton, N8nIcon } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { extractFromAICalls, type INode } from 'n8n-workflow';
|
||||
import { FocusScope } from 'reka-ui';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
|
||||
import type {
|
||||
AgentJsonMcpServerConfig,
|
||||
AgentJsonToolRef,
|
||||
CustomToolEntry,
|
||||
WorkflowToolRef,
|
||||
} from '../types';
|
||||
import {
|
||||
toolRefToNode,
|
||||
updateToolRefFromNode,
|
||||
updateWorkflowToolRef,
|
||||
} from '../composables/useAgentToolRefAdapter';
|
||||
import { nodeToMcpServer } from '../composables/useMcpServerAdapter';
|
||||
import AgentToolConfigApprovalSetting from './AgentToolConfigApprovalSetting.vue';
|
||||
import AgentToolConfigCustomContent from './AgentToolConfigCustomContent.vue';
|
||||
import AgentToolConfigMcpApprovalSetting from './AgentToolConfigMcpApprovalSetting.vue';
|
||||
import AgentToolConfigModalHeader from './AgentToolConfigModalHeader.vue';
|
||||
import AgentToolConfigNodeContent from './AgentToolConfigNodeContent.vue';
|
||||
import AgentToolConfigWorkflowContent from './AgentToolConfigWorkflowContent.vue';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { toolRefToNode } from '../composables/useAgentToolRefAdapter';
|
||||
import AgentModal from './modals/AgentModal.vue';
|
||||
import AgentToolConfigForm, { type AgentToolConfigModalData } from './AgentToolConfigForm.vue';
|
||||
|
||||
interface ToolModalData {
|
||||
toolRef: AgentJsonToolRef;
|
||||
customTool?: CustomToolEntry;
|
||||
existingToolNames?: string[];
|
||||
projectId?: string;
|
||||
agentId?: string;
|
||||
validationIssues?: AgentConfigValidationIssue[];
|
||||
/** Inline agents pass false: approval needs suspend/resume, which workflow executions don't support. */
|
||||
supportsToolApproval?: boolean;
|
||||
onConfirm: (updatedRef: AgentJsonToolRef) => void;
|
||||
onRemove?: () => void;
|
||||
kind?: 'tool';
|
||||
}
|
||||
|
||||
interface McpServerModalData {
|
||||
kind: 'mcpServer';
|
||||
mcpServer: AgentJsonMcpServerConfig;
|
||||
initialNode: INode;
|
||||
existingToolNames?: string[];
|
||||
projectId?: string;
|
||||
agentId?: string;
|
||||
/** Inline agents pass false: approval needs suspend/resume, which workflow executions don't support. */
|
||||
supportsToolApproval?: boolean;
|
||||
onConfirm: (updatedServer: AgentJsonMcpServerConfig) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
type AgentToolConfigModalData = ToolModalData | McpServerModalData;
|
||||
|
||||
// DynamicModalLoader passes `open`/`active`/`mode`/`activeId` alongside the
|
||||
// props we declare. Without this they fall through onto N8nDialog, and the
|
||||
// inherited `open` (always true while mounted) wins over our own binding.
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -77,473 +16,95 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const httpRequestUrlErrorKey =
|
||||
'agents.builder.validation.issue.httpRequestUrlFromAi' as BaseTextKey;
|
||||
const uiStore = useUIStore();
|
||||
const isCredentialModalOpen = computed(
|
||||
() => uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY]?.open === true,
|
||||
);
|
||||
const form = ref<InstanceType<typeof AgentToolConfigForm> | null>(null);
|
||||
const credentialModalOpen = ref(false);
|
||||
const title = ref(initialTitle());
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => uiStore.modalsById[props.modalName]?.open === true,
|
||||
set: (value: boolean) => {
|
||||
if (!value) uiStore.closeModal(props.modalName);
|
||||
},
|
||||
const isOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
|
||||
const isCustomTool = computed(
|
||||
() => props.data.kind !== 'mcpServer' && props.data.toolRef.type === 'custom',
|
||||
);
|
||||
const canRender = computed(() => {
|
||||
if (props.data.kind === 'mcpServer') return Boolean(props.data.initialNode);
|
||||
if (props.data.toolRef.type === 'custom' || props.data.toolRef.type === 'workflow') return true;
|
||||
return toolRefToNode(props.data.toolRef) !== null;
|
||||
});
|
||||
const removeLabel = computed(() => {
|
||||
if (props.data.kind === 'mcpServer') {
|
||||
return i18n.baseText('agents.builder.tools.mcp.remove' as BaseTextKey);
|
||||
}
|
||||
if (props.data.toolRef.type === 'workflow') {
|
||||
return i18n.baseText('agents.builder.tools.workflow.remove' as BaseTextKey);
|
||||
}
|
||||
return i18n.baseText('agents.builder.tools.remove');
|
||||
});
|
||||
|
||||
function isMcpServerModalData(data: AgentToolConfigModalData): data is McpServerModalData {
|
||||
return data.kind === 'mcpServer';
|
||||
function initialTitle(): string {
|
||||
if (props.data.kind === 'mcpServer') return props.data.mcpServer.name;
|
||||
if (props.data.toolRef.type === 'custom') {
|
||||
return props.data.customTool?.descriptor.name ?? props.data.toolRef.id;
|
||||
}
|
||||
return props.data.toolRef.name ?? '';
|
||||
}
|
||||
|
||||
function containsFromAiCall(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return false;
|
||||
try {
|
||||
return extractFromAICalls(value).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const isMcpTool = computed(() => isMcpServerModalData(props.data));
|
||||
const mcpModalData = computed(() => (isMcpServerModalData(props.data) ? props.data : null));
|
||||
const toolModalData = computed(() => (isMcpServerModalData(props.data) ? null : props.data));
|
||||
const isWorkflowTool = computed(() => toolModalData.value?.toolRef.type === 'workflow');
|
||||
const isCustomTool = computed(() => toolModalData.value?.toolRef.type === 'custom');
|
||||
|
||||
const nodeContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | null>(null);
|
||||
const mcpContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | null>(null);
|
||||
const workflowContentRef = ref<InstanceType<typeof AgentToolConfigWorkflowContent> | null>(null);
|
||||
const isValid = ref(false);
|
||||
const approvalRequired = ref(false);
|
||||
const mcpApproval = ref<AgentJsonMcpServerConfig['approval']>();
|
||||
const mcpApprovalValid = ref(true);
|
||||
const draftNode = ref<INode | null>(null);
|
||||
|
||||
const initialNode = computed<INode | null>(() =>
|
||||
isMcpTool.value
|
||||
? (mcpModalData.value?.initialNode ?? null)
|
||||
: isWorkflowTool.value || isCustomTool.value
|
||||
? null
|
||||
: toolModalData.value
|
||||
? toolRefToNode(toolModalData.value.toolRef)
|
||||
: null,
|
||||
);
|
||||
|
||||
const workflowInitialRef = computed<WorkflowToolRef | null>(() =>
|
||||
isWorkflowTool.value && toolModalData.value?.toolRef.type === 'workflow'
|
||||
? toolModalData.value.toolRef
|
||||
: null,
|
||||
);
|
||||
|
||||
const initialName = computed(() => {
|
||||
if (isMcpTool.value) return mcpModalData.value?.mcpServer.name ?? '';
|
||||
const toolName =
|
||||
toolModalData.value?.toolRef.type === 'node' ? toolModalData.value.toolRef.name : undefined;
|
||||
return toolName ?? initialNode.value?.name ?? '';
|
||||
});
|
||||
const nodeName = ref(initialName.value);
|
||||
const customToolCode = computed(() =>
|
||||
!isMcpTool.value ? (toolModalData.value?.customTool?.code ?? '') : '',
|
||||
);
|
||||
const customToolTitle = computed(() => {
|
||||
const toolRef = toolModalData.value?.toolRef;
|
||||
const fallbackName =
|
||||
toolRef?.type === 'custom'
|
||||
? toolRef.id
|
||||
: toolRef?.type === 'workflow' || toolRef?.type === 'node'
|
||||
? toolRef.name
|
||||
: undefined;
|
||||
return (
|
||||
toolModalData.value?.customTool?.descriptor.name ??
|
||||
fallbackName ??
|
||||
i18n.baseText('agents.builder.tree.customBadge')
|
||||
);
|
||||
});
|
||||
|
||||
const canRender = computed(
|
||||
() => isCustomTool.value || isWorkflowTool.value || initialNode.value !== null,
|
||||
);
|
||||
const supportsApproval = computed(() => props.data.supportsToolApproval !== false);
|
||||
const showApprovalSetting = computed(
|
||||
() => supportsApproval.value && !isMcpTool.value && toolModalData.value !== null,
|
||||
);
|
||||
|
||||
watch(
|
||||
() => toolModalData.value?.toolRef,
|
||||
(toolRef) => {
|
||||
approvalRequired.value = Boolean(toolRef?.requireApproval);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => mcpModalData.value?.mcpServer.approval,
|
||||
(approval) => {
|
||||
mcpApproval.value = approval;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
initialNode,
|
||||
(node) => {
|
||||
draftNode.value = node;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const currentNode = computed(() => draftNode.value ?? initialNode.value);
|
||||
const hasHttpRequestUrlIssue = computed(() => {
|
||||
const data = toolModalData.value;
|
||||
if (data?.toolRef.type !== 'node') return false;
|
||||
if (
|
||||
!data.validationIssues?.some(
|
||||
(issue) => issue.code === 'invalid_value' && issue.path.endsWith('.node.nodeParameters.url'),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = currentNode.value?.parameters.url;
|
||||
return containsFromAiCall(url);
|
||||
});
|
||||
const canSave = computed(() => {
|
||||
if (isCustomTool.value) return true;
|
||||
if (isMcpTool.value) return isValid.value && mcpApprovalValid.value;
|
||||
return isValid.value && !hasHttpRequestUrlIssue.value;
|
||||
});
|
||||
const fromAiDisabledParameters = computed(() => {
|
||||
const toolRef = toolModalData.value?.toolRef;
|
||||
if (
|
||||
toolRef?.type === 'node' &&
|
||||
(toolRef.node.nodeType === HTTP_REQUEST_NODE_TYPE ||
|
||||
toolRef.node.nodeType === HTTP_REQUEST_TOOL_NODE_TYPE)
|
||||
) {
|
||||
return ['url'];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const nodeParameterIssues = computed<Record<string, string[]>>(() => {
|
||||
const issues: Record<string, string[]> = {};
|
||||
if (hasHttpRequestUrlIssue.value) {
|
||||
issues.url = [i18n.baseText(httpRequestUrlErrorKey)];
|
||||
}
|
||||
return issues;
|
||||
});
|
||||
|
||||
const headerKind = computed<'node' | 'workflow' | 'custom' | 'mcp'>(() => {
|
||||
if (isCustomTool.value) return 'custom';
|
||||
if (isWorkflowTool.value) return 'workflow';
|
||||
if (isMcpTool.value) return 'mcp';
|
||||
return 'node';
|
||||
});
|
||||
|
||||
const headerNodeTypeDescription = computed(() => {
|
||||
if (isMcpTool.value) {
|
||||
return mcpContentRef.value?.getNodeTypeDescription() ?? null;
|
||||
}
|
||||
|
||||
if (isWorkflowTool.value || isCustomTool.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nodeContentRef.value?.getNodeTypeDescription() ?? null;
|
||||
});
|
||||
|
||||
function closeDialog() {
|
||||
uiStore.closeModal(props.modalName);
|
||||
}
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open) closeDialog();
|
||||
}
|
||||
|
||||
function handleInteractOutside(event: Event) {
|
||||
if (isCredentialModalOpen.value) event.preventDefault();
|
||||
if (credentialModalOpen.value) event.preventDefault();
|
||||
}
|
||||
|
||||
function withApprovalRequirement(ref: AgentJsonToolRef): AgentJsonToolRef {
|
||||
if (!supportsApproval.value) {
|
||||
const { requireApproval: _requireApproval, ...rest } = ref;
|
||||
return rest as AgentJsonToolRef;
|
||||
}
|
||||
const updatedRef = { ...ref };
|
||||
if (approvalRequired.value) {
|
||||
updatedRef.requireApproval = true;
|
||||
} else {
|
||||
delete updatedRef.requireApproval;
|
||||
}
|
||||
return updatedRef;
|
||||
}
|
||||
|
||||
function withMcpApproval(server: AgentJsonMcpServerConfig): AgentJsonMcpServerConfig {
|
||||
const updatedServer = { ...server };
|
||||
if (supportsApproval.value && mcpApproval.value) {
|
||||
updatedServer.approval = mcpApproval.value;
|
||||
} else {
|
||||
delete updatedServer.approval;
|
||||
}
|
||||
return updatedServer;
|
||||
function updateTitle(value: string) {
|
||||
title.value = value;
|
||||
form.value?.changeTitle(value);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (isCustomTool.value) {
|
||||
const toolData = toolModalData.value;
|
||||
if (!toolData) return;
|
||||
toolData.onConfirm(withApprovalRequirement(toolData.toolRef));
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMcpTool.value) {
|
||||
const currentNode = mcpContentRef.value?.getNode();
|
||||
const mcpData = mcpModalData.value;
|
||||
if (!currentNode) return;
|
||||
if (!mcpData) return;
|
||||
const updatedServer = nodeToMcpServer(currentNode, mcpData.mcpServer);
|
||||
mcpData.onConfirm(withMcpApproval(updatedServer));
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWorkflowTool.value) {
|
||||
const wc = workflowContentRef.value;
|
||||
const toolData = toolModalData.value;
|
||||
if (!toolData) return;
|
||||
if (!wc) return;
|
||||
const workflowId = wc.getWorkflowId();
|
||||
const updatedRef = updateWorkflowToolRef(toolData.toolRef, {
|
||||
name: wc.getName(),
|
||||
description: wc.getDescription(),
|
||||
allOutputs: wc.getAllOutputs(),
|
||||
workflow: wc.getWorkflow(),
|
||||
...(workflowId !== undefined ? { workflowId } : {}),
|
||||
inputs: wc.getInputs(),
|
||||
});
|
||||
toolData.onConfirm(withApprovalRequirement(updatedRef));
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentNode = nodeContentRef.value?.getNode();
|
||||
const toolData = toolModalData.value;
|
||||
if (!currentNode) return;
|
||||
if (!toolData) return;
|
||||
const updatedRef = updateToolRefFromNode(toolData.toolRef, currentNode);
|
||||
toolData.onConfirm(withApprovalRequirement(updatedRef));
|
||||
closeDialog();
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
closeDialog();
|
||||
if (form.value?.confirm()) closeDialog();
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
props.data.onRemove?.();
|
||||
form.value?.remove();
|
||||
closeDialog();
|
||||
}
|
||||
|
||||
function handleChangeName(name: string) {
|
||||
if (isCustomTool.value) return;
|
||||
|
||||
if (isMcpTool.value) {
|
||||
mcpContentRef.value?.handleChangeName(name);
|
||||
} else if (isWorkflowTool.value) {
|
||||
workflowContentRef.value?.handleChangeName(name);
|
||||
} else {
|
||||
nodeContentRef.value?.handleChangeName(name);
|
||||
}
|
||||
}
|
||||
|
||||
function handleValidUpdate(valid: boolean) {
|
||||
isValid.value = valid;
|
||||
}
|
||||
|
||||
function handleNodeNameUpdate(name: string) {
|
||||
nodeName.value = name;
|
||||
}
|
||||
|
||||
function handleNodeUpdate(node: INode) {
|
||||
draftNode.value = node;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nDialog
|
||||
<AgentModal
|
||||
v-if="canRender"
|
||||
v-model:open="isOpen"
|
||||
size="2xlarge"
|
||||
:trap-focus="!isCredentialModalOpen"
|
||||
:disable-outside-pointer-events="!isCredentialModalOpen"
|
||||
:show-close-button="false"
|
||||
data-test-id="agent-tool-config-modal"
|
||||
:open="isOpen"
|
||||
:title="title"
|
||||
:editable-title="!isCustomTool"
|
||||
:trap-focus="!credentialModalOpen"
|
||||
:disable-outside-pointer-events="!credentialModalOpen"
|
||||
data-testid="agent-tool-config-modal"
|
||||
@interact-outside="handleInteractOutside"
|
||||
@update:open="onOpenChange"
|
||||
@update:title="updateTitle"
|
||||
>
|
||||
<FocusScope
|
||||
v-if="isCredentialModalOpen"
|
||||
as-child
|
||||
@mount-auto-focus.prevent
|
||||
@unmount-auto-focus.prevent
|
||||
>
|
||||
<span hidden aria-hidden="true" />
|
||||
</FocusScope>
|
||||
<AgentToolConfigForm
|
||||
ref="form"
|
||||
:data="data"
|
||||
@update:title="title = $event"
|
||||
@update:credential-modal-open="credentialModalOpen = $event"
|
||||
/>
|
||||
|
||||
<N8nDialogHeader>
|
||||
<AgentToolConfigModalHeader
|
||||
:kind="headerKind"
|
||||
:title="isCustomTool ? customToolTitle : nodeName"
|
||||
:node-type-description="headerNodeTypeDescription"
|
||||
@update:title="handleChangeName"
|
||||
/>
|
||||
</N8nDialogHeader>
|
||||
<div :class="$style.body">
|
||||
<div :class="[$style.contentWrapper, isCustomTool && $style.codeContentWrapper]">
|
||||
<AgentToolConfigCustomContent
|
||||
v-if="isCustomTool"
|
||||
:code="customToolCode"
|
||||
:class="$style.customToolViewer"
|
||||
/>
|
||||
<AgentToolConfigApprovalSetting
|
||||
v-if="isCustomTool && showApprovalSetting"
|
||||
v-model="approvalRequired"
|
||||
/>
|
||||
<template v-else>
|
||||
<div :class="$style.configureTab">
|
||||
<AgentToolConfigWorkflowContent
|
||||
v-if="workflowInitialRef"
|
||||
ref="workflowContentRef"
|
||||
:initial-ref="workflowInitialRef"
|
||||
:project-id="data.projectId"
|
||||
:show-approval-setting="showApprovalSetting"
|
||||
:approval-required="approvalRequired"
|
||||
@update:valid="handleValidUpdate"
|
||||
@update:node-name="handleNodeNameUpdate"
|
||||
@update:approval-required="approvalRequired = $event"
|
||||
/>
|
||||
<AgentToolConfigNodeContent
|
||||
v-else-if="isMcpTool && initialNode"
|
||||
ref="mcpContentRef"
|
||||
:initial-node="initialNode"
|
||||
:existing-tool-names="data.existingToolNames"
|
||||
:project-id="data.projectId"
|
||||
content-test-id="agent-tool-config-mcp-content"
|
||||
@update:valid="handleValidUpdate"
|
||||
@update:node-name="handleNodeNameUpdate"
|
||||
@update:node="handleNodeUpdate"
|
||||
/>
|
||||
<AgentToolConfigNodeContent
|
||||
v-else-if="initialNode"
|
||||
ref="nodeContentRef"
|
||||
:initial-node="initialNode"
|
||||
:existing-tool-names="data.existingToolNames"
|
||||
:project-id="data.projectId"
|
||||
:from-ai-disabled-parameters="fromAiDisabledParameters"
|
||||
:parameter-issues="nodeParameterIssues"
|
||||
content-test-id="node-tool-settings-content"
|
||||
@update:valid="handleValidUpdate"
|
||||
@update:node-name="handleNodeNameUpdate"
|
||||
@update:node="handleNodeUpdate"
|
||||
/>
|
||||
<AgentToolConfigApprovalSetting
|
||||
v-if="!isMcpTool && initialNode && showApprovalSetting"
|
||||
v-model="approvalRequired"
|
||||
/>
|
||||
<AgentToolConfigMcpApprovalSetting
|
||||
v-if="isMcpTool && currentNode && supportsApproval"
|
||||
v-model="mcpApproval"
|
||||
:node="currentNode"
|
||||
:project-id="data.projectId"
|
||||
@update:valid="mcpApprovalValid = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<N8nDialogFooter>
|
||||
<div :class="$style.footer">
|
||||
<N8nButton
|
||||
v-if="data.onRemove"
|
||||
variant="subtle"
|
||||
data-test-id="agent-tool-config-remove"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
|
||||
{{ i18n.baseText('agents.builder.tools.remove') }}
|
||||
</N8nButton>
|
||||
<div :class="$style.footerActions">
|
||||
<N8nButton variant="subtle" @click="handleCancel">
|
||||
{{ i18n.baseText('agents.toolConfig.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
:disabled="!canSave"
|
||||
data-test-id="agent-tool-config-save"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ i18n.baseText('agents.toolConfig.save') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</N8nDialogFooter>
|
||||
</N8nDialog>
|
||||
<template v-if="data.onRemove" #footerLeft>
|
||||
<N8nButton variant="ghost" data-testid="agent-tool-config-remove" @click="handleRemove">
|
||||
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
|
||||
{{ removeLabel }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
<template #footerActions>
|
||||
<N8nButton variant="solid" data-testid="agent-tool-config-save" @click="handleConfirm">
|
||||
{{ i18n.baseText('generic.save') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</AgentModal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
// Replaces Modal's max-height prop, which N8nDialog has no equivalent for.
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
--agent-tool-config-content-max-height: min(
|
||||
calc(var(--spacing--5xl) * 2),
|
||||
calc(var(--dialog--max-height) - var(--spacing--5xl))
|
||||
);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
max-height: var(--agent-tool-config-content-max-height);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
margin-right: 0;
|
||||
padding: var(--spacing--md) 0;
|
||||
|
||||
:global(.ndv-connection-hint-notice) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.codeContentWrapper {
|
||||
height: var(--agent-tool-config-content-max-height);
|
||||
margin-right: 0;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.configureTab {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.customToolViewer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import NodeIcon from '@/app/components/NodeIcon.vue';
|
||||
import { N8nIcon, N8nInlineTextEdit, N8nText } from '@n8n/design-system';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
type HeaderKind = 'node' | 'workflow' | 'custom' | 'mcp';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
kind: HeaderKind;
|
||||
title: string;
|
||||
nodeTypeDescription?: INodeTypeDescription | null;
|
||||
editable?: boolean;
|
||||
}>(),
|
||||
{
|
||||
nodeTypeDescription: null,
|
||||
editable: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:title': [name: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.header">
|
||||
<NodeIcon
|
||||
v-if="(kind === 'node' || kind === 'mcp') && nodeTypeDescription"
|
||||
:node-type="nodeTypeDescription"
|
||||
:size="24"
|
||||
:circle="true"
|
||||
:class="$style.icon"
|
||||
/>
|
||||
<N8nIcon
|
||||
v-else-if="kind === 'workflow'"
|
||||
icon="workflow"
|
||||
:size="20"
|
||||
:class="$style.workflowHeaderIcon"
|
||||
/>
|
||||
<N8nIcon
|
||||
v-else-if="kind === 'custom'"
|
||||
icon="code"
|
||||
:size="20"
|
||||
:class="$style.customHeaderIcon"
|
||||
/>
|
||||
<N8nInlineTextEdit
|
||||
v-if="editable && kind !== 'custom'"
|
||||
:model-value="title"
|
||||
:max-width="400"
|
||||
:class="$style.title"
|
||||
@update:model-value="emit('update:title', $event)"
|
||||
/>
|
||||
<N8nText v-else :class="$style.title">
|
||||
{{ title }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.workflowHeaderIcon {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
color: var(--color--primary);
|
||||
}
|
||||
|
||||
.customHeaderIcon {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size--md);
|
||||
font-weight: var(--font-weight--regular);
|
||||
line-height: var(--line-height--lg);
|
||||
color: var(--color--text--shade-1);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
+165
-89
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, provide, ref, watch } from 'vue';
|
||||
import { computed, onMounted, provide, ref, shallowRef, watch } from 'vue';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { N8nButton, N8nIcon } from '@n8n/design-system';
|
||||
import { getResourcePermissions } from '@n8n/permissions';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES } from '@n8n/api-types';
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
isCommunityPackageName,
|
||||
resolveSupportedCredentialActivation,
|
||||
} from 'n8n-workflow';
|
||||
import type { INode, INodeProperties, INodeTypeDescription } from 'n8n-workflow';
|
||||
import type { INode, INodeTypeDescription } from 'n8n-workflow';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getWorkflow } from '@/app/api/workflows';
|
||||
@@ -50,7 +51,6 @@ import {
|
||||
type WorkflowConnectionItem,
|
||||
} from '@/features/shared/toolsConnection/types';
|
||||
|
||||
import { AGENT_TOOL_CONFIG_MODAL_KEY } from '../constants';
|
||||
import {
|
||||
getExistingToolNames,
|
||||
nodeTypeToNewToolRef,
|
||||
@@ -68,11 +68,13 @@ import {
|
||||
mcpServerToNode,
|
||||
nodeTypeToNewMcpServer,
|
||||
} from '../composables/useMcpServerAdapter';
|
||||
import type { AgentJsonMcpServerConfig, AgentJsonToolRef, WorkflowToolRef } from '../types';
|
||||
import type { AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types';
|
||||
import type { ToolPickerMode } from './AgentCapabilitiesSection.types';
|
||||
import type { WorkflowToolIncompatibilityReason } from '@n8n/api-types';
|
||||
import { toToolIconSource } from '../utils/toolIconSource';
|
||||
import { workflowToolTriggerLabel } from '../utils/workflowToolTriggers';
|
||||
import AgentToolConfigForm, { type AgentToolConfigModalData } from './AgentToolConfigForm.vue';
|
||||
import AgentModalMultiStep from './modals/AgentModalMultiStep.vue';
|
||||
|
||||
const BASE_CATEGORIES: ToolCategoryKey[] = ['all', 'mcp', 'app-action', 'workflows'];
|
||||
/** Prefix for the synthetic ids of gateway-backed rows in the n8n Connect section. */
|
||||
@@ -201,26 +203,79 @@ watch(
|
||||
|
||||
const workingTools = computed(() => workingToolEntries.value.map(({ ref }) => ref));
|
||||
const workingMcpServers = computed(() => workingMcpServerEntries.value.map(({ server }) => server));
|
||||
const configData = shallowRef<AgentToolConfigModalData | null>(null);
|
||||
const configForm = ref<InstanceType<typeof AgentToolConfigForm> | null>(null);
|
||||
const configTitle = ref('');
|
||||
const configSession = ref(0);
|
||||
const isCredentialModalOpen = ref(false);
|
||||
|
||||
const isConfigModalOpen = computed(
|
||||
() => uiStore.modalsById[AGENT_TOOL_CONFIG_MODAL_KEY]?.open === true,
|
||||
);
|
||||
|
||||
/**
|
||||
* The two dialogs are sequential rather than stacked: connecting a tool hands
|
||||
* over to the config modal, and this one steps aside. It stays open in the
|
||||
* store rather than closing, so cancelling the config brings the list back with
|
||||
* its search and scroll position intact.
|
||||
*/
|
||||
const isOpen = computed({
|
||||
get: () => uiStore.modalsById[props.modalName]?.open === true && !isConfigModalOpen.value,
|
||||
get: () => uiStore.modalsById[props.modalName]?.open === true,
|
||||
set: (value: boolean) => {
|
||||
if (!value) uiStore.closeModal(props.modalName);
|
||||
},
|
||||
});
|
||||
|
||||
function openConfigModal(data: Record<string, unknown>) {
|
||||
uiStore.openModalWithData({ name: AGENT_TOOL_CONFIG_MODAL_KEY, data });
|
||||
const currentStep = computed(() => (configData.value ? 'configure' : 'select'));
|
||||
const pickerTitle = computed(() =>
|
||||
isWorkflow.value ? i18n.baseText('workflows.add') : i18n.baseText('agents.builder.tools.add'),
|
||||
);
|
||||
const modalTitle = computed(() => (configData.value ? configTitle.value : pickerTitle.value));
|
||||
const configIsCustom = computed(
|
||||
() => configData.value?.kind !== 'mcpServer' && configData.value?.toolRef.type === 'custom',
|
||||
);
|
||||
const removeLabel = computed(() => {
|
||||
const data = configData.value;
|
||||
if (data?.kind === 'mcpServer') {
|
||||
return i18n.baseText('agents.builder.tools.mcp.remove' as BaseTextKey);
|
||||
}
|
||||
if (data?.toolRef.type === 'workflow') {
|
||||
return i18n.baseText('agents.builder.tools.workflow.remove' as BaseTextKey);
|
||||
}
|
||||
return i18n.baseText('agents.builder.tools.remove');
|
||||
});
|
||||
|
||||
function initialConfigTitle(data: AgentToolConfigModalData): string {
|
||||
if (data.kind === 'mcpServer') return data.mcpServer.name;
|
||||
if (data.toolRef.type === 'custom') {
|
||||
return data.customTool?.descriptor.name ?? data.toolRef.id;
|
||||
}
|
||||
return data.toolRef.name ?? '';
|
||||
}
|
||||
|
||||
function openConfigModal(data: AgentToolConfigModalData) {
|
||||
configData.value = data;
|
||||
configTitle.value = initialConfigTitle(data);
|
||||
configSession.value += 1;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
uiStore.closeModal(props.modalName);
|
||||
configData.value = null;
|
||||
}
|
||||
|
||||
function backToPicker() {
|
||||
configData.value = null;
|
||||
isCredentialModalOpen.value = false;
|
||||
configTitle.value = '';
|
||||
}
|
||||
|
||||
function updateConfigTitle(value: string) {
|
||||
configTitle.value = value;
|
||||
configForm.value?.changeTitle(value);
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
if (configForm.value?.confirm()) closeModal();
|
||||
}
|
||||
|
||||
function removeConfig() {
|
||||
configForm.value?.remove();
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function handleInteractOutside(event: Event) {
|
||||
if (isCredentialModalOpen.value) event.preventDefault();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -237,20 +292,6 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function hasRequiredCredentials(nodeType: INodeTypeDescription): boolean {
|
||||
return (nodeType.credentials ?? []).some((credential) => credential.required !== false);
|
||||
}
|
||||
|
||||
function isConfigurableParameter(parameter: INodeProperties): boolean {
|
||||
return parameter.type !== 'notice' && parameter.type !== 'hidden';
|
||||
}
|
||||
|
||||
function needsSetup(nodeType: INodeTypeDescription): boolean {
|
||||
return (
|
||||
hasRequiredCredentials(nodeType) || (nodeType.properties ?? []).some(isConfigurableParameter)
|
||||
);
|
||||
}
|
||||
|
||||
function makeUniqueName(
|
||||
baseName: string,
|
||||
existingNames: string[],
|
||||
@@ -291,11 +332,6 @@ function commit() {
|
||||
function addToolRef(savedRef: AgentJsonToolRef) {
|
||||
workingToolEntries.value = [...workingToolEntries.value, { localId: uuidv4(), ref: savedRef }];
|
||||
commit();
|
||||
uiStore.closeModal(props.modalName);
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('agents.tools.added'),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
function addMcpServer(savedServer: AgentJsonMcpServerConfig) {
|
||||
@@ -304,11 +340,6 @@ function addMcpServer(savedServer: AgentJsonMcpServerConfig) {
|
||||
{ localId: uuidv4(), server: savedServer },
|
||||
];
|
||||
commit();
|
||||
uiStore.closeModal(props.modalName);
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('agents.tools.mcp.added'),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
function openConfigForNewRef(newRef: AgentJsonToolRef) {
|
||||
@@ -416,14 +447,8 @@ async function handleAddTool(nodeType: INodeTypeDescription) {
|
||||
function addNodeTool(nodeType: INodeTypeDescription) {
|
||||
toolTelemetry.trackAddStarted('node');
|
||||
const newRef = nodeTypeToNewToolRef(nodeType);
|
||||
|
||||
if (needsSetup(nodeType)) {
|
||||
openConfigForNewRef(newRef);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newRef.type === 'node') {
|
||||
addToolRef({
|
||||
openConfigForNewRef({
|
||||
...newRef,
|
||||
name: makeUniqueName(
|
||||
newRef.name ?? nodeType.displayName,
|
||||
@@ -431,7 +456,7 @@ function addNodeTool(nodeType: INodeTypeDescription) {
|
||||
),
|
||||
});
|
||||
} else {
|
||||
addToolRef({
|
||||
openConfigForNewRef({
|
||||
...newRef,
|
||||
});
|
||||
}
|
||||
@@ -551,7 +576,6 @@ function openConfigForToolEntry(entry: WorkingToolEntry) {
|
||||
);
|
||||
toolTelemetry.trackEdited(updatedRef);
|
||||
commit();
|
||||
uiStore.closeModal(props.modalName);
|
||||
},
|
||||
onRemove: () => {
|
||||
workingToolEntries.value = workingToolEntries.value.filter(
|
||||
@@ -579,7 +603,6 @@ function openConfigForMcpEntry(entry: WorkingMcpServerEntry) {
|
||||
e.localId === entry.localId ? { ...e, server: updatedServer } : e,
|
||||
);
|
||||
commit();
|
||||
uiStore.closeModal(props.modalName);
|
||||
},
|
||||
onRemove: () => {
|
||||
workingMcpServerEntries.value = workingMcpServerEntries.value.filter(
|
||||
@@ -604,21 +627,6 @@ function credentialSubtitle(node: INode): string | undefined {
|
||||
|
||||
function connectedToolItem(entry: WorkingToolEntry): ToolConnectionItem | null {
|
||||
const { localId, ref } = entry;
|
||||
if (ref.type === 'workflow') {
|
||||
const workflowRef = ref as WorkflowToolRef;
|
||||
const item: WorkflowConnectionItem = {
|
||||
id: `tool:${localId}`,
|
||||
kind: 'workflow',
|
||||
category: 'workflows',
|
||||
workflowId: workflowRef.workflowId ?? workflowRef.workflow,
|
||||
title: workflowRef.name ?? workflowRef.workflow,
|
||||
description: workflowRef.description,
|
||||
status: 'connected',
|
||||
credentials: [],
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
if (ref.type !== 'node') return null;
|
||||
|
||||
const node = toolRefToNode(ref);
|
||||
@@ -820,6 +828,14 @@ const items = computed<ToolConnectionItem[]>(() => {
|
||||
return out;
|
||||
});
|
||||
|
||||
function addActionLabel(item: ToolConnectionItem): string {
|
||||
if (item.category === 'mcp') {
|
||||
return i18n.baseText('agents.builder.tools.mcp.add' as BaseTextKey);
|
||||
}
|
||||
if (item.kind === 'workflow') return i18n.baseText('workflows.add');
|
||||
return i18n.baseText('node.addNode');
|
||||
}
|
||||
|
||||
function handleRowActivate(item: ToolConnectionItem) {
|
||||
// Disabled rows (e.g. incompatible workflows) are visible-but-not-selectable;
|
||||
// the row's own tooltip already explains why, so activating does nothing.
|
||||
@@ -889,28 +905,88 @@ function handleRowActivate(item: ToolConnectionItem) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolsConnectionModal
|
||||
v-model:open="isOpen"
|
||||
:items="items"
|
||||
:categories="categories"
|
||||
:title="isWorkflow ? i18n.baseText('generic.workflows') : undefined"
|
||||
:searchPlaceholder="
|
||||
isWorkflow ? i18n.baseText('agents.tools.workflow.search.placeholder') : undefined
|
||||
"
|
||||
size="2xlarge"
|
||||
:detail-item="null"
|
||||
:allowWorkflowCreation="isWorkflow && canCreateWorkflow"
|
||||
:workflowCreationLoading="isCreatingWorkflow"
|
||||
@update:search-query="searchQuery = $event"
|
||||
@connect="handleRowActivate"
|
||||
@open-detail="handleRowActivate"
|
||||
@create-workflow="handleCreateWorkflow"
|
||||
<AgentModalMultiStep
|
||||
:open="isOpen"
|
||||
:step="currentStep"
|
||||
:title="modalTitle"
|
||||
:editable-title="Boolean(configData) && !configIsCustom"
|
||||
:show-back="Boolean(configData)"
|
||||
:show-footer="Boolean(configData)"
|
||||
:busy="isCredentialModalOpen || isCreatingWorkflow"
|
||||
:trap-focus="!isCredentialModalOpen"
|
||||
:disable-outside-pointer-events="!isCredentialModalOpen"
|
||||
data-testid="agent-tools-connection-modal"
|
||||
@interact-outside="handleInteractOutside"
|
||||
@update:open="isOpen = $event"
|
||||
@update:title="updateConfigTitle"
|
||||
@back="backToPicker"
|
||||
>
|
||||
<template #suggestion-footer>
|
||||
<McpRegistrySuggestionFooter
|
||||
:prompt="i18n.baseText('agents.tools.suggestion.prompt')"
|
||||
:action="i18n.baseText('agents.tools.suggestion.action')"
|
||||
/>
|
||||
<ToolsConnectionModal
|
||||
v-show="!configData"
|
||||
:open="isOpen"
|
||||
:items="items"
|
||||
:categories="categories"
|
||||
:title="pickerTitle"
|
||||
:search-placeholder="
|
||||
isWorkflow ? i18n.baseText('agents.tools.workflow.search.placeholder') : undefined
|
||||
"
|
||||
:detail-item="null"
|
||||
:create-action="
|
||||
isWorkflow && canCreateWorkflow
|
||||
? {
|
||||
category: 'workflows',
|
||||
label: i18n.baseText('generic.create.workflow'),
|
||||
description: i18n.baseText('projectRoles.workflow:create.tooltip'),
|
||||
testId: 'tools-connection-create-workflow',
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:create-action-loading="isCreatingWorkflow"
|
||||
:empty-message="isWorkflow ? i18n.baseText('agents.tools.workflow.empty.title') : undefined"
|
||||
:no-results-message="
|
||||
isWorkflow ? i18n.baseText('agents.tools.workflow.empty.noResults') : undefined
|
||||
"
|
||||
:connect-label="addActionLabel"
|
||||
embedded
|
||||
show-connect-actions
|
||||
persistent-scrollbar
|
||||
@update:search-query="searchQuery = $event"
|
||||
@connect="handleRowActivate"
|
||||
@open-detail="handleRowActivate"
|
||||
@create="handleCreateWorkflow"
|
||||
>
|
||||
<template #suggestion-footer>
|
||||
<McpRegistrySuggestionFooter
|
||||
:prompt="i18n.baseText('agents.tools.suggestion.prompt')"
|
||||
:action="i18n.baseText('agents.tools.suggestion.action')"
|
||||
/>
|
||||
</template>
|
||||
</ToolsConnectionModal>
|
||||
|
||||
<AgentToolConfigForm
|
||||
v-if="configData"
|
||||
:key="configSession"
|
||||
ref="configForm"
|
||||
:data="configData"
|
||||
@update:title="configTitle = $event"
|
||||
@update:credential-modal-open="isCredentialModalOpen = $event"
|
||||
/>
|
||||
|
||||
<template v-if="configData?.onRemove" #footerLeft>
|
||||
<N8nButton variant="ghost" data-testid="agent-tool-config-remove" @click="removeConfig">
|
||||
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
|
||||
{{ removeLabel }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</ToolsConnectionModal>
|
||||
<template v-if="configData" #footerActions>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
:disabled="isCredentialModalOpen"
|
||||
data-testid="agent-tool-config-save"
|
||||
@click="saveConfig"
|
||||
>
|
||||
{{ i18n.baseText('generic.save') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</AgentModalMultiStep>
|
||||
</template>
|
||||
|
||||
+240
-265
@@ -2,13 +2,12 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import type { AgentJsonVectorStoreConfig, AgentVectorStoreProvider } from '@n8n/api-types';
|
||||
import { VECTOR_STORE_NAME_REGEX, VECTOR_STORE_USE_WHEN_MAX_LENGTH } from '@n8n/api-types';
|
||||
import { N8nButton, N8nFormInput, N8nHeading, N8nInputLabel, N8nText } from '@n8n/design-system';
|
||||
import type { IValidator, Rule, RuleGroup, Validatable } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { N8nButton, N8nFormInput, N8nIcon, N8nInputLabel, N8nText } from '@n8n/design-system';
|
||||
import type { Rule, RuleGroup } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { getResourcePermissions } from '@n8n/permissions';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
||||
import Modal from '@/app/components/Modal.vue';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
} from '../vector-stores';
|
||||
import AgentCredentialSelect, { type AgentCredentialOption } from './AgentCredentialSelect.vue';
|
||||
import EmbeddingModelSelector from './EmbeddingModelSelector.vue';
|
||||
import AgentModalMultiStep from './modals/AgentModalMultiStep.vue';
|
||||
|
||||
type AddVectorStoreModalData = {
|
||||
projectId: string;
|
||||
@@ -58,6 +58,7 @@ const { showMessage, showError } = useToast();
|
||||
const rootStore = useRootStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
|
||||
|
||||
const providerOrder: AgentVectorStoreProvider[] = ['pinecone', 'supabase', 'qdrant', 'postgres'];
|
||||
|
||||
@@ -68,9 +69,15 @@ const selectedProvider = ref<AgentVectorStoreProvider | null>(
|
||||
const providerDefinition = computed(() =>
|
||||
selectedProvider.value ? AGENT_VECTOR_STORE_PROVIDER_DEFINITIONS[selectedProvider.value] : null,
|
||||
);
|
||||
const currentStep = computed(() => (selectedProvider.value ? 'configure' : 'select'));
|
||||
const modalTitle = computed(() =>
|
||||
selectedProvider.value ? name.value : i18n.baseText('agents.builder.vectorStores.modal.title'),
|
||||
);
|
||||
|
||||
const existing = props.data.vectorStore;
|
||||
const name = ref(existing?.name ?? '');
|
||||
const name = ref(
|
||||
existing?.name ?? i18n.baseText('agents.builder.vectorStores.modal.defaultName' as BaseTextKey),
|
||||
);
|
||||
const nameTouched = ref(isEditing.value);
|
||||
const credential = ref(existing?.credential ?? '');
|
||||
const indexName = ref(existing && existing.provider === 'pinecone' ? existing.indexName : '');
|
||||
@@ -93,6 +100,7 @@ const embeddingCredential = ref(existing?.embedding.credential ?? '');
|
||||
const useWhen = ref(existing?.useWhen ?? '');
|
||||
|
||||
const testing = ref(false);
|
||||
const submitted = ref(false);
|
||||
|
||||
const credentialsByType = ref<Record<string, AgentCredentialOption[]>>({});
|
||||
const credentialsLoading = ref(false);
|
||||
@@ -104,7 +112,6 @@ const pendingCredentialType = ref<string | null>(null);
|
||||
// already handles "don't show the error until the field is touched" internally
|
||||
// (validateOnBlur) — this only drives the Connect/Save button's disabled state.
|
||||
const formValidation = reactive({
|
||||
name: false,
|
||||
locator: false,
|
||||
useWhen: false,
|
||||
});
|
||||
@@ -113,21 +120,32 @@ function closeModal() {
|
||||
uiStore.closeModal(props.modalName);
|
||||
}
|
||||
|
||||
function handleInteractOutside(event: Event) {
|
||||
if (credentialModalOpen.value) event.preventDefault();
|
||||
}
|
||||
|
||||
function selectProvider(provider: AgentVectorStoreProvider) {
|
||||
submitted.value = false;
|
||||
selectedProvider.value = provider;
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (isEditing.value) return;
|
||||
submitted.value = false;
|
||||
selectedProvider.value = null;
|
||||
nameTouched.value = false;
|
||||
name.value = '';
|
||||
name.value = i18n.baseText('agents.builder.vectorStores.modal.defaultName' as BaseTextKey);
|
||||
credential.value = '';
|
||||
indexName.value = '';
|
||||
namespace.value = '';
|
||||
collectionName.value = '';
|
||||
tableName.value = '';
|
||||
queryName.value = '';
|
||||
embeddingModel.value = '';
|
||||
embeddingCredential.value = '';
|
||||
useWhen.value = '';
|
||||
formValidation.locator = false;
|
||||
formValidation.useWhen = false;
|
||||
}
|
||||
|
||||
const locatorValue = computed(() => {
|
||||
@@ -148,47 +166,35 @@ const locatorValue = computed(() => {
|
||||
// user edits it directly, so add mode doesn't start with an empty required field.
|
||||
watch(locatorValue, (value) => {
|
||||
if (isEditing.value || nameTouched.value) return;
|
||||
name.value = value
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, '_')
|
||||
.slice(0, 64);
|
||||
const locator = value.trim();
|
||||
if (!locator) return;
|
||||
name.value = locator.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 64);
|
||||
});
|
||||
|
||||
function onNameInput(value: Validatable) {
|
||||
function onNameInput(value: string) {
|
||||
nameTouched.value = true;
|
||||
name.value = typeof value === 'string' ? value : '';
|
||||
name.value = value;
|
||||
}
|
||||
|
||||
const nameValidators: Record<string, IValidator> = {
|
||||
NAME_UNIQUE: {
|
||||
validate: (value: Validatable) => {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
const otherNames = props.data.existingNames.filter(
|
||||
(otherName) => otherName !== existing?.name,
|
||||
);
|
||||
// '-' is sanitized to '_' in the derived search_<name> tool name, so
|
||||
// compare sanitized forms to catch collisions like 'docs-a' vs 'docs_a'.
|
||||
const sanitize = (value: string) => value.replace(/-/g, '_');
|
||||
if (trimmed && otherNames.some((otherName) => sanitize(otherName) === sanitize(trimmed))) {
|
||||
return {
|
||||
message: i18n.baseText('agents.builder.vectorStores.modal.name.validation.duplicate'),
|
||||
};
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const nameValidationRules: Array<Rule | RuleGroup> = [
|
||||
{
|
||||
name: 'MATCH_REGEX',
|
||||
config: {
|
||||
regex: VECTOR_STORE_NAME_REGEX,
|
||||
message: i18n.baseText('agents.builder.vectorStores.modal.name.validation.pattern'),
|
||||
},
|
||||
},
|
||||
{ name: 'NAME_UNIQUE' },
|
||||
];
|
||||
const nameError = computed(() => {
|
||||
const trimmed = name.value.trim();
|
||||
if (!trimmed) {
|
||||
return i18n.baseText(
|
||||
'agents.builder.vectorStores.modal.name.validation.required' as BaseTextKey,
|
||||
);
|
||||
}
|
||||
if (!VECTOR_STORE_NAME_REGEX.test(trimmed)) {
|
||||
return i18n.baseText('agents.builder.vectorStores.modal.name.validation.pattern');
|
||||
}
|
||||
const otherNames = props.data.existingNames.filter((otherName) => otherName !== existing?.name);
|
||||
// '-' becomes '_' in the generated tool name. Compare the generated forms.
|
||||
const sanitize = (value: string) => value.replace(/-/g, '_');
|
||||
if (otherNames.some((otherName) => sanitize(otherName) === sanitize(trimmed))) {
|
||||
return i18n.baseText('agents.builder.vectorStores.modal.name.validation.duplicate');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const visibleNameError = computed(() => (submitted.value ? nameError.value : ''));
|
||||
|
||||
const useWhenValidationRules: Array<Rule | RuleGroup> = [
|
||||
{ name: 'MAX_LENGTH', config: { maximum: VECTOR_STORE_USE_WHEN_MAX_LENGTH } },
|
||||
@@ -197,7 +203,7 @@ const useWhenValidationRules: Array<Rule | RuleGroup> = [
|
||||
const canTest = computed(
|
||||
() =>
|
||||
Boolean(selectedProvider.value) &&
|
||||
formValidation.name &&
|
||||
!nameError.value &&
|
||||
Boolean(credential.value) &&
|
||||
formValidation.locator &&
|
||||
Boolean(embeddingModel.value) &&
|
||||
@@ -364,6 +370,7 @@ function buildVectorStoreConfig(): AgentJsonVectorStoreConfig {
|
||||
}
|
||||
|
||||
async function onTestAndConnect() {
|
||||
submitted.value = true;
|
||||
if (!canTest.value || testing.value) return;
|
||||
testing.value = true;
|
||||
try {
|
||||
@@ -383,13 +390,15 @@ async function onTestAndConnect() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const successTitle = i18n.baseText('agents.builder.vectorStores.modal.test.successTitle', {
|
||||
interpolate: { name: vectorStore.name },
|
||||
});
|
||||
if (result.warning) {
|
||||
showMessage({ title: successTitle, message: result.warning, type: 'warning', duration: 0 });
|
||||
} else {
|
||||
showMessage({ title: successTitle, type: 'success' });
|
||||
showMessage({
|
||||
title: i18n.baseText('agents.builder.vectorStores.modal.test.successTitle', {
|
||||
interpolate: { name: vectorStore.name },
|
||||
}),
|
||||
message: result.warning,
|
||||
type: 'warning',
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
props.data.onConfirm(vectorStore);
|
||||
closeModal();
|
||||
@@ -412,231 +421,212 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:name="props.modalName"
|
||||
width="640px"
|
||||
:custom-class="$style.modal"
|
||||
<AgentModalMultiStep
|
||||
:open="modalOpen"
|
||||
:step="currentStep"
|
||||
:title="modalTitle"
|
||||
:editable-title="Boolean(selectedProvider)"
|
||||
:title-placeholder="i18n.baseText('agents.builder.vectorStores.modal.name.placeholder')"
|
||||
:title-error="visibleNameError"
|
||||
:title-max-length="64"
|
||||
:show-back="Boolean(selectedProvider) && !isEditing"
|
||||
:show-footer="Boolean(selectedProvider)"
|
||||
:busy="testing"
|
||||
:trap-focus="!credentialModalOpen"
|
||||
:disable-outside-pointer-events="!credentialModalOpen"
|
||||
data-testid="agent-vector-stores-modal"
|
||||
@interact-outside="handleInteractOutside"
|
||||
@update:open="!$event && closeModal()"
|
||||
@update:title="onNameInput"
|
||||
@back="goBack"
|
||||
>
|
||||
<template #header>
|
||||
<N8nHeading tag="h2" size="large">
|
||||
{{
|
||||
selectedProvider
|
||||
? providerDefinition?.displayName
|
||||
: i18n.baseText('agents.builder.vectorStores.modal.title')
|
||||
}}
|
||||
</N8nHeading>
|
||||
</template>
|
||||
<div v-show="!selectedProvider" :class="$style.content">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.vectorStores.modal.description') }}
|
||||
</N8nText>
|
||||
|
||||
<template #content>
|
||||
<div v-if="!selectedProvider" :class="$style.content">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.vectorStores.modal.description') }}
|
||||
</N8nText>
|
||||
|
||||
<div :class="$style.rows">
|
||||
<div
|
||||
v-for="provider in providerOrder"
|
||||
:key="provider"
|
||||
:class="$style.row"
|
||||
data-testid="agent-vector-stores-modal-row"
|
||||
>
|
||||
<div :class="$style.iconWrapper">
|
||||
<CredentialIcon
|
||||
:credential-type-name="
|
||||
AGENT_VECTOR_STORE_PROVIDER_DEFINITIONS[provider].credentialType
|
||||
"
|
||||
:size="24"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.rowBody">
|
||||
<N8nText size="small" color="text-dark" :class="$style.name">
|
||||
{{ AGENT_VECTOR_STORE_PROVIDER_DEFINITIONS[provider].displayName }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<div :class="$style.actions">
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
size="small"
|
||||
data-testid="agent-vector-stores-modal-connect"
|
||||
@click="selectProvider(provider)"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.vectorStores.modal.connect') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<div :class="$style.rows">
|
||||
<div
|
||||
v-for="provider in providerOrder"
|
||||
:key="provider"
|
||||
:class="$style.row"
|
||||
role="group"
|
||||
:aria-label="AGENT_VECTOR_STORE_PROVIDER_DEFINITIONS[provider].displayName"
|
||||
data-testid="agent-vector-stores-modal-row"
|
||||
>
|
||||
<div :class="$style.iconWrapper">
|
||||
<CredentialIcon
|
||||
:credential-type-name="
|
||||
AGENT_VECTOR_STORE_PROVIDER_DEFINITIONS[provider].credentialType
|
||||
"
|
||||
:size="24"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.rowBody">
|
||||
<N8nText size="small" color="text-dark" :class="$style.name">
|
||||
{{ AGENT_VECTOR_STORE_PROVIDER_DEFINITIONS[provider].displayName }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<div :class="$style.actions">
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
size="small"
|
||||
data-testid="agent-vector-stores-modal-connect"
|
||||
@click="selectProvider(provider)"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.vectorStores.modal.connect') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else :class="[$style.content, $style.configureContent]">
|
||||
<N8nFormInput
|
||||
:model-value="name"
|
||||
name="vectorStoreName"
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.name.label')"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.name.placeholder')"
|
||||
required
|
||||
:maxlength="64"
|
||||
:validation-rules="nameValidationRules"
|
||||
:validators="nameValidators"
|
||||
data-testid="agent-vector-stores-modal-name"
|
||||
@update:model-value="onNameInput"
|
||||
@validate="(valid: boolean) => (formValidation.name = valid)"
|
||||
<div v-if="selectedProvider" :class="[$style.content, $style.configureContent]">
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.credential.label')"
|
||||
required
|
||||
>
|
||||
<AgentCredentialSelect
|
||||
v-model="credential"
|
||||
:credentials="storeCredentialOptions"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.credential.placeholder')"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:loading="credentialsLoading"
|
||||
data-test-id="agent-vector-stores-modal-credential"
|
||||
@create="onCreateStoreCredential"
|
||||
/>
|
||||
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.credential.label')"
|
||||
required
|
||||
<N8nText
|
||||
v-if="submitted && !credential"
|
||||
size="small"
|
||||
color="danger"
|
||||
data-testid="agent-vector-stores-modal-credential-required"
|
||||
>
|
||||
<AgentCredentialSelect
|
||||
v-model="credential"
|
||||
:credentials="storeCredentialOptions"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.credential.placeholder')"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:loading="credentialsLoading"
|
||||
data-test-id="agent-vector-stores-modal-credential"
|
||||
@create="onCreateStoreCredential"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
|
||||
<template v-if="selectedProvider === 'pinecone'">
|
||||
<N8nFormInput
|
||||
v-model="indexName"
|
||||
name="indexName"
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.indexName.label')"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.indexName.placeholder')"
|
||||
required
|
||||
data-testid="agent-vector-stores-modal-index-name"
|
||||
@validate="(valid: boolean) => (formValidation.locator = valid)"
|
||||
/>
|
||||
<N8nFormInput
|
||||
v-model="namespace"
|
||||
name="namespace"
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.namespace.label')"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.namespace.placeholder')"
|
||||
data-testid="agent-vector-stores-modal-namespace"
|
||||
/>
|
||||
</template>
|
||||
{{
|
||||
i18n.baseText('agents.builder.vectorStores.modal.credential.required' as BaseTextKey)
|
||||
}}
|
||||
</N8nText>
|
||||
</N8nInputLabel>
|
||||
|
||||
<template v-if="selectedProvider === 'pinecone'">
|
||||
<N8nFormInput
|
||||
v-else-if="selectedProvider === 'qdrant'"
|
||||
v-model="collectionName"
|
||||
name="collectionName"
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.collectionName.label')"
|
||||
:placeholder="
|
||||
i18n.baseText('agents.builder.vectorStores.modal.collectionName.placeholder')
|
||||
"
|
||||
v-model="indexName"
|
||||
name="indexName"
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.indexName.label')"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.indexName.placeholder')"
|
||||
required
|
||||
data-testid="agent-vector-stores-modal-collection-name"
|
||||
:show-validation-warnings="submitted"
|
||||
data-testid="agent-vector-stores-modal-index-name"
|
||||
@validate="(valid: boolean) => (formValidation.locator = valid)"
|
||||
/>
|
||||
|
||||
<template v-else-if="selectedProvider === 'supabase' || selectedProvider === 'postgres'">
|
||||
<N8nFormInput
|
||||
v-model="tableName"
|
||||
name="tableName"
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.tableName.label')"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.tableName.placeholder')"
|
||||
required
|
||||
data-testid="agent-vector-stores-modal-table-name"
|
||||
@validate="(valid: boolean) => (formValidation.locator = valid)"
|
||||
/>
|
||||
<N8nFormInput
|
||||
v-if="selectedProvider === 'supabase'"
|
||||
v-model="queryName"
|
||||
name="queryName"
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.queryName.label')"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.queryName.placeholder')"
|
||||
data-testid="agent-vector-stores-modal-query-name"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.embeddingModel.label')"
|
||||
:tooltip-text="i18n.baseText('agents.builder.vectorStores.modal.embeddingModel.hint')"
|
||||
required
|
||||
>
|
||||
<EmbeddingModelSelector
|
||||
:selected-model="embeddingModel"
|
||||
:selected-credential-id="embeddingCredential || null"
|
||||
:credentials-by-type="credentialsByType"
|
||||
:can-create-credentials="credentialPermissions.create"
|
||||
@update:selected-model="onEmbeddingModelUpdate"
|
||||
@update:selected-credential-id="embeddingCredential = $event"
|
||||
@create-credential="onCreateEmbeddingCredential"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
|
||||
<N8nFormInput
|
||||
v-model="useWhen"
|
||||
name="useWhen"
|
||||
:label="i18n.baseText('agents.builder.vectorStores.useWhen.label')"
|
||||
:tooltip-text="i18n.baseText('agents.builder.vectorStores.useWhen.hint')"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.useWhen.placeholder')"
|
||||
required
|
||||
:maxlength="VECTOR_STORE_USE_WHEN_MAX_LENGTH"
|
||||
:validation-rules="useWhenValidationRules"
|
||||
data-testid="agent-vector-stores-modal-use-when"
|
||||
@validate="(valid: boolean) => (formValidation.useWhen = valid)"
|
||||
v-model="namespace"
|
||||
name="namespace"
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.namespace.label')"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.namespace.placeholder')"
|
||||
data-testid="agent-vector-stores-modal-namespace"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="selectedProvider" #footer>
|
||||
<div :class="$style.footer">
|
||||
<N8nButton
|
||||
v-if="isEditing && data.onRemove"
|
||||
variant="subtle"
|
||||
data-testid="agent-vector-stores-modal-remove"
|
||||
@click="onRemove"
|
||||
<N8nFormInput
|
||||
v-else-if="selectedProvider === 'qdrant'"
|
||||
v-model="collectionName"
|
||||
name="collectionName"
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.collectionName.label')"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.collectionName.placeholder')"
|
||||
required
|
||||
:show-validation-warnings="submitted"
|
||||
data-testid="agent-vector-stores-modal-collection-name"
|
||||
@validate="(valid: boolean) => (formValidation.locator = valid)"
|
||||
/>
|
||||
|
||||
<template v-else-if="selectedProvider === 'supabase' || selectedProvider === 'postgres'">
|
||||
<N8nFormInput
|
||||
v-model="tableName"
|
||||
name="tableName"
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.tableName.label')"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.tableName.placeholder')"
|
||||
required
|
||||
:show-validation-warnings="submitted"
|
||||
data-testid="agent-vector-stores-modal-table-name"
|
||||
@validate="(valid: boolean) => (formValidation.locator = valid)"
|
||||
/>
|
||||
<N8nFormInput
|
||||
v-if="selectedProvider === 'supabase'"
|
||||
v-model="queryName"
|
||||
name="queryName"
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.queryName.label')"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.modal.queryName.placeholder')"
|
||||
data-testid="agent-vector-stores-modal-query-name"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('agents.builder.vectorStores.modal.embeddingModel.label')"
|
||||
:tooltip-text="i18n.baseText('agents.builder.vectorStores.modal.embeddingModel.hint')"
|
||||
required
|
||||
>
|
||||
<EmbeddingModelSelector
|
||||
:selected-model="embeddingModel"
|
||||
:selected-credential-id="embeddingCredential || null"
|
||||
:credentials-by-type="credentialsByType"
|
||||
:can-create-credentials="credentialPermissions.create"
|
||||
@update:selected-model="onEmbeddingModelUpdate"
|
||||
@update:selected-credential-id="embeddingCredential = $event"
|
||||
@create-credential="onCreateEmbeddingCredential"
|
||||
/>
|
||||
<N8nText
|
||||
v-if="submitted && (!embeddingModel || !embeddingCredential)"
|
||||
size="small"
|
||||
color="danger"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.vectorStores.modal.remove') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
v-else
|
||||
variant="subtle"
|
||||
data-testid="agent-vector-stores-modal-back"
|
||||
@click="goBack"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.vectorStores.modal.back') }}
|
||||
</N8nButton>
|
||||
<div :class="$style.footerActions">
|
||||
<N8nButton variant="subtle" @click="closeModal">
|
||||
{{ i18n.baseText('generic.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
:disabled="!canTest"
|
||||
:loading="testing"
|
||||
data-testid="agent-vector-stores-modal-confirm"
|
||||
@click="onTestAndConnect"
|
||||
>
|
||||
{{
|
||||
i18n.baseText(
|
||||
isEditing ? 'generic.save' : 'agents.builder.vectorStores.modal.connect',
|
||||
)
|
||||
}}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
{{
|
||||
i18n.baseText(
|
||||
'agents.builder.vectorStores.modal.embeddingModel.required' as BaseTextKey,
|
||||
)
|
||||
}}
|
||||
</N8nText>
|
||||
</N8nInputLabel>
|
||||
|
||||
<N8nFormInput
|
||||
v-model="useWhen"
|
||||
name="useWhen"
|
||||
:label="i18n.baseText('agents.builder.vectorStores.useWhen.label')"
|
||||
:tooltip-text="i18n.baseText('agents.builder.vectorStores.useWhen.hint')"
|
||||
:placeholder="i18n.baseText('agents.builder.vectorStores.useWhen.placeholder')"
|
||||
required
|
||||
:maxlength="VECTOR_STORE_USE_WHEN_MAX_LENGTH"
|
||||
:validation-rules="useWhenValidationRules"
|
||||
:show-validation-warnings="submitted"
|
||||
data-testid="agent-vector-stores-modal-use-when"
|
||||
@validate="(valid: boolean) => (formValidation.useWhen = valid)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="selectedProvider && isEditing && data.onRemove" #footerLeft>
|
||||
<N8nButton variant="ghost" data-testid="agent-vector-stores-modal-remove" @click="onRemove">
|
||||
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
|
||||
{{ i18n.baseText('agents.builder.vectorStores.modal.remove') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</Modal>
|
||||
<template v-if="selectedProvider" #footerActions>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
:loading="testing"
|
||||
:disabled="testing"
|
||||
data-testid="agent-vector-stores-modal-confirm"
|
||||
@click="onTestAndConnect"
|
||||
>
|
||||
{{ i18n.baseText('generic.save') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</AgentModalMultiStep>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.modal {
|
||||
:global(.modal-content) {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--md);
|
||||
margin: calc(-1 * var(--spacing--lg));
|
||||
padding: var(--spacing--lg);
|
||||
max-height: 640px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.configureContent {
|
||||
@@ -659,7 +649,7 @@ onMounted(() => {
|
||||
|
||||
.iconWrapper {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
width: var(--spacing--xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -682,25 +672,10 @@ onMounted(() => {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
color: var(--text-color--subtle);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
-1
@@ -587,7 +587,6 @@ defineExpose({
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--md);
|
||||
padding: var(--spacing--sm) 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
N8nDialog,
|
||||
N8nButton,
|
||||
N8nDialogFooter,
|
||||
N8nDialogHeader,
|
||||
N8nDialogTitle,
|
||||
N8nIcon,
|
||||
N8nIconButton,
|
||||
N8nInlineTextEdit,
|
||||
N8nText,
|
||||
type DialogSize,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { FocusScope } from 'reka-ui';
|
||||
import { computed, nextTick, useSlots, useTemplateRef } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
title: string;
|
||||
editableTitle?: boolean;
|
||||
titlePlaceholder?: string;
|
||||
titleMaxLength?: number;
|
||||
titleError?: string;
|
||||
showBack?: boolean;
|
||||
showFooter?: boolean;
|
||||
showCancel?: boolean;
|
||||
bodyScrollable?: boolean;
|
||||
bodyFlush?: boolean;
|
||||
busy?: boolean;
|
||||
size?: DialogSize;
|
||||
stacked?: boolean;
|
||||
trapFocus?: boolean;
|
||||
disableOutsidePointerEvents?: boolean;
|
||||
}>(),
|
||||
{
|
||||
editableTitle: false,
|
||||
titlePlaceholder: '',
|
||||
titleMaxLength: 128,
|
||||
titleError: '',
|
||||
showBack: false,
|
||||
showFooter: undefined,
|
||||
showCancel: true,
|
||||
bodyScrollable: true,
|
||||
bodyFlush: false,
|
||||
busy: false,
|
||||
size: '2xlarge',
|
||||
stacked: false,
|
||||
trapFocus: true,
|
||||
disableOutsidePointerEvents: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
'update:title': [value: string];
|
||||
back: [];
|
||||
interactOutside: [event: Event];
|
||||
}>();
|
||||
|
||||
const slots = useSlots();
|
||||
const i18n = useI18n();
|
||||
const body = useTemplateRef<HTMLElement>('body');
|
||||
const hasFooter = computed(
|
||||
() =>
|
||||
props.showFooter ??
|
||||
Boolean(slots.footer || slots.footerLeft || slots.footerBeforeCancel || slots.footerActions),
|
||||
);
|
||||
const dismissalBlocked = computed(() => props.busy || !props.trapFocus);
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open && dismissalBlocked.value) return;
|
||||
emit('update:open', open);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!dismissalBlocked.value) emit('update:open', false);
|
||||
}
|
||||
|
||||
function onBack() {
|
||||
if (!dismissalBlocked.value) emit('back');
|
||||
}
|
||||
|
||||
function onEscapeKeyDown(event: KeyboardEvent) {
|
||||
if (dismissalBlocked.value) event.preventDefault();
|
||||
}
|
||||
|
||||
function onInteractOutside(event: Event) {
|
||||
if (dismissalBlocked.value) event.preventDefault();
|
||||
emit('interactOutside', event);
|
||||
}
|
||||
|
||||
function onOpenAutoFocus(event: Event) {
|
||||
const autofocusTarget =
|
||||
body.value?.querySelector<HTMLElement>(
|
||||
'[data-agent-modal-autofocus], input:not([type="hidden"]):not([disabled]):not([tabindex="-1"]), textarea:not([disabled]), select:not([disabled]), [contenteditable="true"]',
|
||||
) ?? body.value?.querySelector<HTMLElement>('button:not([disabled])');
|
||||
if (!autofocusTarget) return;
|
||||
|
||||
event.preventDefault();
|
||||
void nextTick(() => autofocusTarget.focus());
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nDialog
|
||||
:open="props.open"
|
||||
:size="props.size"
|
||||
:stacked="props.stacked"
|
||||
:trap-focus="props.trapFocus"
|
||||
:disable-outside-pointer-events="props.disableOutsidePointerEvents"
|
||||
:show-close-button="false"
|
||||
@escape-key-down="onEscapeKeyDown"
|
||||
@interact-outside="onInteractOutside"
|
||||
@open-auto-focus="onOpenAutoFocus"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<N8nDialogHeader :class="$style.header">
|
||||
<div :class="$style.headerContent">
|
||||
<N8nIconButton
|
||||
v-if="props.showBack"
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
:disabled="dismissalBlocked"
|
||||
:class="$style.backButton"
|
||||
:aria-label="i18n.baseText('generic.back')"
|
||||
data-testid="agent-modal-back"
|
||||
@click="onBack"
|
||||
/>
|
||||
|
||||
<N8nDialogTitle as-child>
|
||||
<div :class="$style.titleGroup">
|
||||
<N8nInlineTextEdit
|
||||
v-if="props.editableTitle"
|
||||
:model-value="props.title"
|
||||
:max-length="props.titleMaxLength"
|
||||
:max-width="'100%'"
|
||||
:placeholder="props.titlePlaceholder"
|
||||
:disabled="props.busy"
|
||||
:class="$style.editableTitle"
|
||||
@update:model-value="emit('update:title', $event)"
|
||||
/>
|
||||
<span v-else :class="$style.title">{{ props.title }}</span>
|
||||
<N8nIcon
|
||||
v-if="props.editableTitle"
|
||||
icon="pencil"
|
||||
size="small"
|
||||
:class="$style.editIcon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</N8nDialogTitle>
|
||||
|
||||
<div v-if="$slots.headerActions" :class="$style.headerActions">
|
||||
<slot name="headerActions" />
|
||||
</div>
|
||||
|
||||
<N8nIconButton
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
:disabled="dismissalBlocked"
|
||||
:aria-label="i18n.baseText('generic.close')"
|
||||
data-testid="dialog-close-button"
|
||||
:class="$style.closeButton"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
<N8nText
|
||||
v-if="props.titleError"
|
||||
size="small"
|
||||
color="danger"
|
||||
:class="[$style.titleError, props.showBack && $style.titleErrorWithBack]"
|
||||
>
|
||||
{{ props.titleError }}
|
||||
</N8nText>
|
||||
</N8nDialogHeader>
|
||||
|
||||
<FocusScope
|
||||
v-if="!props.trapFocus"
|
||||
as-child
|
||||
@mount-auto-focus.prevent
|
||||
@unmount-auto-focus.prevent
|
||||
>
|
||||
<span hidden aria-hidden="true" />
|
||||
</FocusScope>
|
||||
|
||||
<div
|
||||
ref="body"
|
||||
:class="[
|
||||
$style.body,
|
||||
!props.bodyScrollable && $style.bodyNotScrollable,
|
||||
props.bodyFlush && $style.bodyFlush,
|
||||
]"
|
||||
data-testid="agent-modal-body"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<N8nDialogFooter v-if="hasFooter" :class="$style.footer">
|
||||
<slot name="footer">
|
||||
<div :class="$style.footerLayout">
|
||||
<div :class="$style.footerLeft">
|
||||
<slot name="footerLeft" />
|
||||
</div>
|
||||
<div :class="$style.footerActions" data-testid="agent-modal-footer-actions">
|
||||
<slot name="footerBeforeCancel" />
|
||||
<N8nButton
|
||||
v-if="props.showCancel"
|
||||
variant="outline"
|
||||
:disabled="dismissalBlocked"
|
||||
data-testid="agent-modal-cancel"
|
||||
@click="close"
|
||||
>
|
||||
{{ i18n.baseText('generic.cancel') }}
|
||||
</N8nButton>
|
||||
<slot name="footerActions" />
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</N8nDialogFooter>
|
||||
</N8nDialog>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
@use '@n8n/design-system/css/mixins/mixins' as scrollbar-mixins;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: calc(var(--spacing--lg) * -1) calc(var(--spacing--lg) * -1) 0;
|
||||
padding: var(--spacing--md) var(--spacing--lg);
|
||||
border-bottom: var(--border);
|
||||
}
|
||||
|
||||
.titleError {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titleErrorWithBack {
|
||||
padding-left: calc(var(--spacing--xl) + var(--spacing--2xs));
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backButton,
|
||||
.closeButton {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titleGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
font-size: var(--font-size--lg);
|
||||
font-weight: var(--font-weight--medium);
|
||||
line-height: var(--line-height--lg);
|
||||
color: light-dark(var(--color--neutral-900), var(--color--neutral-100));
|
||||
|
||||
&:hover .editIcon,
|
||||
&:focus-within .editIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editableTitle {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.editIcon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
color: var(--color--text--tint-1);
|
||||
transition: opacity var(--duration--snappy) ease;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.headerActions + .closeButton,
|
||||
.titleGroup + .closeButton {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.body {
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
max-height: min(70dvh, calc(var(--height--5xl) * 6));
|
||||
overflow-y: auto;
|
||||
margin-inline: calc(var(--spacing--5xs) * -1);
|
||||
padding: var(--spacing--md) var(--spacing--5xs) var(--spacing--5xs);
|
||||
|
||||
@include scrollbar-mixins.scroll-bar;
|
||||
}
|
||||
|
||||
.body :global(.n8n-markdown) {
|
||||
@include scrollbar-mixins.scroll-bar;
|
||||
}
|
||||
|
||||
.bodyNotScrollable {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.bodyFlush {
|
||||
margin-inline: calc(var(--spacing--lg) * -1);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.footerLayout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footerLeft,
|
||||
.footerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding-inline: var(--spacing--md);
|
||||
}
|
||||
|
||||
.footerLayout {
|
||||
align-items: stretch;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.footerLeft,
|
||||
.footerActions {
|
||||
width: 100%;
|
||||
|
||||
:global(.button) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.editIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogSize } from '@n8n/design-system';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import AgentModal from './AgentModal.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
step: string;
|
||||
title: string;
|
||||
editableTitle?: boolean;
|
||||
titlePlaceholder?: string;
|
||||
titleMaxLength?: number;
|
||||
titleError?: string;
|
||||
showBack?: boolean;
|
||||
showFooter?: boolean;
|
||||
showCancel?: boolean;
|
||||
busy?: boolean;
|
||||
size?: DialogSize;
|
||||
stacked?: boolean;
|
||||
trapFocus?: boolean;
|
||||
disableOutsidePointerEvents?: boolean;
|
||||
}>(),
|
||||
{
|
||||
editableTitle: false,
|
||||
titlePlaceholder: '',
|
||||
titleMaxLength: 128,
|
||||
titleError: '',
|
||||
showBack: false,
|
||||
showFooter: undefined,
|
||||
showCancel: true,
|
||||
busy: false,
|
||||
size: '2xlarge',
|
||||
stacked: false,
|
||||
trapFocus: true,
|
||||
disableOutsidePointerEvents: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
'update:title': [value: string];
|
||||
back: [];
|
||||
interactOutside: [event: Event];
|
||||
}>();
|
||||
|
||||
const isPickerStep = computed(() => props.step === 'select' || props.step === 'list');
|
||||
const stepChanged = ref(false);
|
||||
watch(
|
||||
() => props.step,
|
||||
async () => {
|
||||
stepChanged.value = false;
|
||||
await nextTick();
|
||||
stepChanged.value = true;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentModal
|
||||
:open="open"
|
||||
:title="title"
|
||||
:editable-title="editableTitle"
|
||||
:title-placeholder="titlePlaceholder"
|
||||
:title-max-length="titleMaxLength"
|
||||
:title-error="titleError"
|
||||
:show-back="showBack"
|
||||
:show-footer="showFooter"
|
||||
:show-cancel="showCancel"
|
||||
:body-scrollable="!isPickerStep"
|
||||
:busy="busy"
|
||||
:size="size"
|
||||
:stacked="stacked"
|
||||
:trap-focus="trapFocus"
|
||||
:disable-outside-pointer-events="disableOutsidePointerEvents"
|
||||
@interact-outside="emit('interactOutside', $event)"
|
||||
@update:open="emit('update:open', $event)"
|
||||
@update:title="emit('update:title', $event)"
|
||||
@back="emit('back')"
|
||||
>
|
||||
<template #headerActions>
|
||||
<slot name="headerActions" />
|
||||
</template>
|
||||
|
||||
<div :class="[$style.step, stepChanged && $style.stepChanged]" :data-step="step">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<template v-if="$slots.footerLeft" #footerLeft>
|
||||
<slot name="footerLeft" />
|
||||
</template>
|
||||
<template v-if="$slots.footerBeforeCancel" #footerBeforeCancel>
|
||||
<slot name="footerBeforeCancel" />
|
||||
</template>
|
||||
<template v-if="$slots.footerActions" #footerActions>
|
||||
<slot name="footerActions" />
|
||||
</template>
|
||||
<template v-if="$slots.footer" #footer>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</AgentModal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
@use '@n8n/design-system/css/mixins/motion';
|
||||
|
||||
.step {
|
||||
min-height: 0;
|
||||
|
||||
&[data-step='select'],
|
||||
&[data-step='list'] {
|
||||
min-height: min(60dvh, calc(var(--height--5xl) * 5));
|
||||
}
|
||||
}
|
||||
|
||||
.stepChanged {
|
||||
--animation--fade-in--duration: var(--duration--snappy);
|
||||
--animation--fade-in--translate: var(--spacing--2xs);
|
||||
|
||||
@include motion.fade-in;
|
||||
}
|
||||
</style>
|
||||
+44
-13
@@ -85,6 +85,18 @@ export interface UseAgentCapabilitiesActionsDeps {
|
||||
* Hosts whose agent always exists omit it.
|
||||
*/
|
||||
ensureAgentPersisted?: () => Promise<void>;
|
||||
/**
|
||||
* Flushes pending agent edits before an API mutation that also writes the
|
||||
* agent config. This prevents the mutation from advancing the config hash
|
||||
* ahead of a queued config save.
|
||||
*/
|
||||
beforeAgentMutation?: () => Promise<void>;
|
||||
/**
|
||||
* Reloads agent-owned state after an API mutation that writes both a sidecar
|
||||
* resource and the agent config. The reload updates the config hash without
|
||||
* scheduling a duplicate config write.
|
||||
*/
|
||||
refreshAgentAfterMutation?: (projectId: string, agentId: string) => Promise<boolean>;
|
||||
validationIssues?: Ref<AgentConfigValidationIssue[]> | ComputedRef<AgentConfigValidationIssue[]>;
|
||||
telemetry?: AgentCapabilitiesTelemetry;
|
||||
}
|
||||
@@ -107,6 +119,8 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
||||
localSkills,
|
||||
supportsToolApproval,
|
||||
ensureAgentPersisted,
|
||||
beforeAgentMutation,
|
||||
refreshAgentAfterMutation,
|
||||
validationIssues,
|
||||
telemetry,
|
||||
} = deps;
|
||||
@@ -414,6 +428,8 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
||||
// still the current one.
|
||||
const targetProjectId = projectId.value;
|
||||
const targetAgentId = agentId.value;
|
||||
const isCurrentTarget = () =>
|
||||
projectId.value === targetProjectId && agentId.value === targetAgentId;
|
||||
|
||||
uiStore.openModalWithData({
|
||||
name: AGENT_SKILL_MODAL_KEY,
|
||||
@@ -424,7 +440,7 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
||||
existingSkillNames: appliedSkillNames(),
|
||||
onConfirm: ({ skill }: { id?: string; skill: AgentSkill }) => {
|
||||
if (localSkills) {
|
||||
if (agentId.value !== targetAgentId) return;
|
||||
if (!isCurrentTarget()) return;
|
||||
const sanitizedSkill = filterSkillAllowedTools(skill);
|
||||
if (hasDuplicateSkillName(sanitizedSkill.name)) {
|
||||
showDuplicateSkillNameError(sanitizedSkill.name);
|
||||
@@ -433,10 +449,6 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
||||
|
||||
// The host mints the skill id and writes body + ref together.
|
||||
localSkills.createSkill(sanitizedSkill);
|
||||
showMessage({
|
||||
title: locale.baseText('agents.builder.skills.added'),
|
||||
type: 'success',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -446,8 +458,17 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
||||
let skillHash: string;
|
||||
let versionId: string | null;
|
||||
let skillId: string;
|
||||
try {
|
||||
await beforeAgentMutation?.();
|
||||
} catch {
|
||||
// The host owns the autosave error message. Do not also report
|
||||
// this as a skill-creation failure.
|
||||
return;
|
||||
}
|
||||
if (!isCurrentTarget()) return;
|
||||
try {
|
||||
await ensureAgentPersisted?.();
|
||||
if (!isCurrentTarget()) return;
|
||||
const result = await createAgentSkill(
|
||||
rootStore.restApiContext,
|
||||
targetProjectId,
|
||||
@@ -462,7 +483,7 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
||||
showError(error, locale.baseText('agents.builder.skills.create.error'));
|
||||
return;
|
||||
}
|
||||
if (agent.value?.id !== targetAgentId) return;
|
||||
if (!isCurrentTarget() || agent.value?.id !== targetAgentId) return;
|
||||
agent.value = {
|
||||
...agent.value,
|
||||
versionId,
|
||||
@@ -475,13 +496,23 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
|
||||
[skillId]: created,
|
||||
},
|
||||
};
|
||||
scheduleConfigUpdate({
|
||||
skills: [...(localConfig.value?.skills ?? []), { type: 'skill', id: skillId }],
|
||||
});
|
||||
showMessage({
|
||||
title: locale.baseText('agents.builder.skills.added'),
|
||||
type: 'success',
|
||||
});
|
||||
let refreshed = true;
|
||||
try {
|
||||
refreshed =
|
||||
(await refreshAgentAfterMutation?.(targetProjectId, targetAgentId)) ?? true;
|
||||
} catch (error) {
|
||||
showError(error, locale.baseText('agents.builder.loadError'));
|
||||
return;
|
||||
}
|
||||
if (refreshed && isCurrentTarget() && localConfig.value) {
|
||||
localConfig.value = {
|
||||
...localConfig.value,
|
||||
skills: [
|
||||
...(localConfig.value.skills ?? []).filter((ref) => ref.id !== skillId),
|
||||
{ type: 'skill', id: skillId },
|
||||
],
|
||||
};
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onBeforeUnmount, useTemplateRef } from 'vue';
|
||||
import { StorageSerializers, useEventListener, useLocalStorage, useStorage } from '@vueuse/core';
|
||||
import {
|
||||
StorageSerializers,
|
||||
useElementSize,
|
||||
useEventListener,
|
||||
useLocalStorage,
|
||||
useStorage,
|
||||
} from '@vueuse/core';
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
N8nAssistantIcon,
|
||||
@@ -180,6 +186,11 @@ const mcpStore = useMCPStore();
|
||||
const mcp = useMcp();
|
||||
const { isCtrlKeyPressed } = useDeviceSupport();
|
||||
|
||||
// No design tokens cover these layout widths. Keep the editor usable while the
|
||||
// two resizable side panels adapt to the available viewport.
|
||||
const AGENT_BUILDER_EDITOR_MIN_WIDTH = 480;
|
||||
const AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH = 320;
|
||||
|
||||
// Gates the Knowledge Base files table (upload, list, sandbox fetch/warmup) on
|
||||
// the backend: Daytona sandbox env vars (N8N_AGENTS_AI_SANDBOX_ENABLED +
|
||||
// PROVIDER=daytona) OR AI Assistant proxy availability. The Knowledge tab and
|
||||
@@ -223,6 +234,10 @@ const previewOpenStorageKey = computed(function getPreviewOpenStorageKey() {
|
||||
const persistedPreviewOpen = useStorage(previewOpenStorageKey, false);
|
||||
const previewDockWidth = ref(480);
|
||||
const isPreviewDockResizing = ref(false);
|
||||
const builderContainer = useTemplateRef<HTMLElement>('builderContainer');
|
||||
const { width: builderContainerWidth } = useElementSize(builderContainer, undefined, {
|
||||
box: 'border-box',
|
||||
});
|
||||
const isPreviewDockOpen = computed(function isPreviewDockOpen() {
|
||||
return !isStandalonePreview.value && persistedPreviewOpen.value;
|
||||
});
|
||||
@@ -300,7 +315,94 @@ watch(aiPanelRef, (panel) => {
|
||||
});
|
||||
const isEditingLocked = computed(() => props.artifactEditingLocked || embeddedAiBuilding.value);
|
||||
const aiPanelWidth = useStorage('N8N_AGENT_AI_PANEL_WIDTH', 400);
|
||||
type SidePanel = 'assistant' | 'preview';
|
||||
const preferredSidePanel = ref<SidePanel>('assistant');
|
||||
|
||||
function bothSidePanelsFit() {
|
||||
if (builderContainerWidth.value === 0) return true;
|
||||
return (
|
||||
builderContainerWidth.value >=
|
||||
AGENT_BUILDER_EDITOR_MIN_WIDTH + AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH * 2
|
||||
);
|
||||
}
|
||||
|
||||
const renderedSidePanelWidths = computed(function getRenderedSidePanelWidths() {
|
||||
const desiredAiWidth = Math.max(aiPanelWidth.value, AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH);
|
||||
const desiredPreviewWidth = Math.max(previewDockWidth.value, AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH);
|
||||
const containerWidth = builderContainerWidth.value;
|
||||
if (containerWidth === 0) {
|
||||
return { ai: desiredAiWidth, preview: desiredPreviewWidth };
|
||||
}
|
||||
|
||||
const availableSidePanelWidth = Math.max(
|
||||
AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH,
|
||||
containerWidth - AGENT_BUILDER_EDITOR_MIN_WIDTH,
|
||||
);
|
||||
if (showAiPanel.value && !isPreviewDockOpen.value) {
|
||||
return {
|
||||
ai: Math.min(desiredAiWidth, availableSidePanelWidth),
|
||||
preview: desiredPreviewWidth,
|
||||
};
|
||||
}
|
||||
if (!showAiPanel.value && isPreviewDockOpen.value) {
|
||||
return {
|
||||
ai: desiredAiWidth,
|
||||
preview: Math.min(desiredPreviewWidth, availableSidePanelWidth),
|
||||
};
|
||||
}
|
||||
if (!showAiPanel.value || !isPreviewDockOpen.value || !bothSidePanelsFit()) {
|
||||
return { ai: desiredAiWidth, preview: desiredPreviewWidth };
|
||||
}
|
||||
|
||||
if (desiredAiWidth + desiredPreviewWidth <= availableSidePanelWidth) {
|
||||
return { ai: desiredAiWidth, preview: desiredPreviewWidth };
|
||||
}
|
||||
|
||||
const availableExtraWidth = availableSidePanelWidth - AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH * 2;
|
||||
const desiredAiExtraWidth = desiredAiWidth - AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH;
|
||||
const desiredPreviewExtraWidth = desiredPreviewWidth - AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH;
|
||||
const desiredExtraWidth = desiredAiExtraWidth + desiredPreviewExtraWidth;
|
||||
const renderedAiWidth =
|
||||
AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH +
|
||||
availableExtraWidth * (desiredAiExtraWidth / desiredExtraWidth);
|
||||
|
||||
return {
|
||||
ai: renderedAiWidth,
|
||||
preview: availableSidePanelWidth - renderedAiWidth,
|
||||
};
|
||||
});
|
||||
|
||||
function keepPreferredSidePanel() {
|
||||
if (!showAiPanel.value || !isPreviewDockOpen.value || bothSidePanelsFit()) return;
|
||||
if (preferredSidePanel.value === 'preview') {
|
||||
isAiPanelOpen.value = false;
|
||||
return;
|
||||
}
|
||||
closePreviewDock();
|
||||
}
|
||||
|
||||
watch(
|
||||
showAiPanel,
|
||||
(open, wasOpen) => {
|
||||
if (open && !wasOpen) preferredSidePanel.value = 'assistant';
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
watch(
|
||||
isPreviewDockOpen,
|
||||
(open, wasOpen) => {
|
||||
if (open && !wasOpen) preferredSidePanel.value = 'preview';
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
watch(
|
||||
[builderContainerWidth, showAiPanel, isPreviewDockOpen, aiPanelWidth, previewDockWidth],
|
||||
keepPreferredSidePanel,
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
function onAiPanelResize({ width }: { width: number }) {
|
||||
preferredSidePanel.value = 'assistant';
|
||||
aiPanelWidth.value = width;
|
||||
}
|
||||
function toggleAiPanel() {
|
||||
@@ -794,6 +896,7 @@ function sessionIdForPreview(): string | undefined {
|
||||
}
|
||||
|
||||
async function openPreview(preferredSessionId?: string) {
|
||||
preferredSidePanel.value = 'preview';
|
||||
const sessionId = preferredSessionId ?? sessionIdForPreview();
|
||||
activeChatSessionId.value = sessionId ?? null;
|
||||
persistedPreviewOpen.value = true;
|
||||
@@ -890,6 +993,7 @@ function closePreviewDock() {
|
||||
}
|
||||
|
||||
function onPreviewDockResize({ width }: ResizeData) {
|
||||
preferredSidePanel.value = 'preview';
|
||||
previewDockWidth.value = width;
|
||||
}
|
||||
|
||||
@@ -1460,6 +1564,8 @@ const caps = useAgentCapabilitiesActions({
|
||||
agentId,
|
||||
connectedTriggers,
|
||||
ensureAgentPersisted,
|
||||
beforeAgentMutation: flushAutosave,
|
||||
refreshAgentAfterMutation: onConfigUpdated,
|
||||
validationIssues: computed(() => configValidation.value?.issues ?? []),
|
||||
scheduleConfigUpdate: onConfigFieldUpdate,
|
||||
scheduleSkillSave: ({ skillId, skill }) => {
|
||||
@@ -1509,22 +1615,29 @@ function persistMissingPersonalisationGradient() {
|
||||
replaceConfigAndScheduleSave(nextConfig);
|
||||
}
|
||||
|
||||
async function onConfigUpdated() {
|
||||
async function onConfigUpdated(
|
||||
targetProjectId: string = projectId.value,
|
||||
targetAgentId: string = agentId.value,
|
||||
): Promise<boolean> {
|
||||
// Modal flows (e.g. skill creation) write through their own API calls, not
|
||||
// `saveConfig` — notify other surfaces (canvas agent cards) here too.
|
||||
agentsEventBus.emit('agentUpdated', { agentId: agentId.value, source: 'agent-builder' });
|
||||
agentsEventBus.emit('agentUpdated', { agentId: targetAgentId, source: 'agent-builder' });
|
||||
await Promise.all([
|
||||
fetchAgent(),
|
||||
fetchConfig(projectId.value, agentId.value),
|
||||
refreshConfigValidation(projectId.value, agentId.value),
|
||||
fetchAgent(targetProjectId, targetAgentId),
|
||||
fetchConfig(targetProjectId, targetAgentId),
|
||||
refreshConfigValidation(targetProjectId, targetAgentId),
|
||||
]);
|
||||
if (isStaleAgentTarget(targetProjectId, targetAgentId)) return false;
|
||||
// Refresh the connected-trigger list so chips reflect builder writes
|
||||
// without waiting for a tab switch. Mirrors the initial baseline fetch.
|
||||
const integrations = await ensureIntegrationsCatalog(projectId.value).catch(() => []);
|
||||
const integrations = await ensureIntegrationsCatalog(targetProjectId).catch(() => []);
|
||||
if (isStaleAgentTarget(targetProjectId, targetAgentId)) return false;
|
||||
const triggerTypes = integrations.map((i) => i.type);
|
||||
const connected = await builderTelemetry.fetchInitialTriggersBaseline(triggerTypes);
|
||||
if (isStaleAgentTarget(targetProjectId, targetAgentId)) return false;
|
||||
if (connected) connectedTriggers.value = connected;
|
||||
tasksReloadKey.value += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function refreshArtifactShell() {
|
||||
@@ -2271,21 +2384,7 @@ function onOpenEditVectorStoreModal(vectorStore: AgentJsonVectorStoreConfig) {
|
||||
});
|
||||
}
|
||||
|
||||
async function onRemoveVectorStore(vectorStore: AgentJsonVectorStoreConfig) {
|
||||
const confirmed = await openAgentConfirmationModal({
|
||||
title: locale.baseText('agents.builder.vectorStores.panel.removeModal.title', {
|
||||
interpolate: { name: vectorStore.name },
|
||||
}),
|
||||
description: locale.baseText('agents.builder.vectorStores.panel.removeModal.description', {
|
||||
interpolate: { name: vectorStore.name },
|
||||
}),
|
||||
confirmButtonText: locale.baseText(
|
||||
'agents.builder.vectorStores.panel.removeModal.button.remove',
|
||||
),
|
||||
cancelButtonText: locale.baseText('generic.cancel'),
|
||||
});
|
||||
if (confirmed !== MODAL_CONFIRM) return;
|
||||
|
||||
function onRemoveVectorStore(vectorStore: AgentJsonVectorStoreConfig) {
|
||||
onConfigFieldUpdate({
|
||||
vectorStores: (localConfig.value?.vectorStores ?? []).filter(
|
||||
(existing) => existing.name !== vectorStore.name,
|
||||
@@ -2402,6 +2501,7 @@ function onSwitchAgent(nextAgentId: string) {
|
||||
</div>
|
||||
<div
|
||||
ref="builderContainer"
|
||||
data-testid="agent-builder-container"
|
||||
:class="[
|
||||
$style.builder,
|
||||
{
|
||||
@@ -2411,14 +2511,15 @@ function onSwitchAgent(nextAgentId: string) {
|
||||
},
|
||||
]"
|
||||
:style="{
|
||||
'--agent-ai-panel-width': `${aiPanelWidth}px`,
|
||||
'--agent-preview-chat-column-width': `${previewDockWidth}px`,
|
||||
'--agent-ai-panel-width': `${renderedSidePanelWidths.ai}px`,
|
||||
'--agent-preview-chat-column-width': `${renderedSidePanelWidths.preview}px`,
|
||||
'--agent-builder-editor-min-width': `${AGENT_BUILDER_EDITOR_MIN_WIDTH}px`,
|
||||
}"
|
||||
>
|
||||
<aside v-if="showAiPanel" :class="$style.aiDock" data-testid="agent-ai-dock">
|
||||
<N8nResizeWrapper
|
||||
:width="aiPanelWidth"
|
||||
:min-width="320"
|
||||
:width="renderedSidePanelWidths.ai"
|
||||
:min-width="AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH"
|
||||
:max-width="720"
|
||||
:supported-directions="['right']"
|
||||
@resize="onAiPanelResize"
|
||||
@@ -2530,8 +2631,8 @@ function onSwitchAgent(nextAgentId: string) {
|
||||
<N8nResizeWrapper
|
||||
v-if="!isStandalonePreview"
|
||||
:class="[$style.previewResizeWrapper, { [$style.previewResizeOpen]: isPreviewDockOpen }]"
|
||||
:width="previewDockWidth"
|
||||
:min-width="320"
|
||||
:width="renderedSidePanelWidths.preview"
|
||||
:min-width="AGENT_BUILDER_SIDE_PANEL_MIN_WIDTH"
|
||||
:supported-directions="['left']"
|
||||
:grid-size="8"
|
||||
@resizestart="isPreviewDockResizing = true"
|
||||
|
||||
+9
-1
@@ -37,11 +37,17 @@ type Props = {
|
||||
isSharedGlobally?: boolean;
|
||||
allUsersLabel?: string;
|
||||
disabledTooltip?: string;
|
||||
teleported?: boolean;
|
||||
// Show the dropdown chevron even in remote+filterable mode (element-plus hides it by default)
|
||||
showSuffix?: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
teleported: true,
|
||||
});
|
||||
|
||||
// Keep an in-place popper outside a scroll container's clipping area.
|
||||
const inPlacePopperOptions: { strategy: 'fixed' } = { strategy: 'fixed' };
|
||||
|
||||
const GLOBAL_GROUP: ProjectListItem = {
|
||||
id: 'all_users',
|
||||
@@ -231,6 +237,8 @@ watch(
|
||||
:size="size ?? 'medium'"
|
||||
:disabled="props.readonly || !!props.disabledTooltip"
|
||||
:clearable
|
||||
:teleported="props.teleported"
|
||||
:popper-options="props.teleported ? undefined : inPlacePopperOptions"
|
||||
:popper-class="$style.popper"
|
||||
@update:model-value="onProjectSelected"
|
||||
@clear="emit('clear')"
|
||||
|
||||
+14
-35
@@ -297,17 +297,6 @@ const renderComponent = createComponentRenderer(CredentialEdit, {
|
||||
}),
|
||||
});
|
||||
|
||||
const modalLoadingStub = {
|
||||
props: ['loading'],
|
||||
template: `
|
||||
<div data-test-id="credential-edit-modal-stub" :data-loading="String(loading)">
|
||||
<slot v-if="!loading" name="header" />
|
||||
<slot v-if="!loading" name="content" />
|
||||
<slot v-if="!loading" name="footer" />
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
let broadcastMessageListener: ((event: MessageEvent) => void) | undefined;
|
||||
|
||||
class BroadcastChannelMock {
|
||||
@@ -349,6 +338,18 @@ describe('CredentialEdit', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('closes through the dialog shell', async () => {
|
||||
const uiStore = mockedStore(useUIStore);
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { modalName: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' },
|
||||
});
|
||||
|
||||
await retry(() => expect(getByTestId('editCredential-modal')).toBeInTheDocument());
|
||||
await userEvent.click(getByTestId('dialog-close-button'));
|
||||
|
||||
await waitFor(() => expect(uiStore.closeModal).toHaveBeenCalledWith(CREDENTIAL_EDIT_MODAL_KEY));
|
||||
});
|
||||
|
||||
test('shows the save button when credentialId is null', async () => {
|
||||
const pinia = createTestingPinia({
|
||||
initialState: {
|
||||
@@ -562,15 +563,9 @@ describe('CredentialEdit', () => {
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { modalName: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' },
|
||||
global: {
|
||||
stubs: {
|
||||
Modal: modalLoadingStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('credential-edit-modal-stub')).toHaveAttribute('data-loading', 'false');
|
||||
expect(getByTestId('credential-edit-dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -587,20 +582,12 @@ describe('CredentialEdit', () => {
|
||||
modalName: CREDENTIAL_EDIT_MODAL_KEY,
|
||||
mode: 'edit',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Modal: modalLoadingStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await waitFor(() => {
|
||||
expect(credentialsStore.getCredentialData).toHaveBeenCalled();
|
||||
expect(getByTestId('credential-edit-modal-stub')).toHaveAttribute(
|
||||
'data-loading',
|
||||
'false',
|
||||
);
|
||||
expect(getByTestId('credential-edit-dialog')).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
@@ -876,20 +863,12 @@ describe('CredentialEdit', () => {
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { modalName: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' },
|
||||
global: {
|
||||
stubs: {
|
||||
Modal: modalLoadingStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Wait for the modal to appear and loading to finish
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('credential-edit-modal-stub')).toHaveAttribute(
|
||||
'data-loading',
|
||||
'false',
|
||||
);
|
||||
expect(getByTestId('editCredential-modal')).toBeInTheDocument();
|
||||
expect(getByTestId('credential-edit-dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
+222
-181
@@ -23,7 +23,6 @@ import CredentialIcon from '../CredentialIcon.vue';
|
||||
import CredentialConfig from './CredentialConfig.vue';
|
||||
import CredentialInfo from './CredentialInfo.vue';
|
||||
import CredentialSharing from './CredentialSharing.ee.vue';
|
||||
import Modal from '@/app/components/Modal.vue';
|
||||
import SaveButton from '@/app/components/SaveButton.vue';
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
|
||||
@@ -62,9 +61,13 @@ import { useElementSize } from '@vueuse/core';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
N8nDialog,
|
||||
N8nDialogHeader,
|
||||
N8nDialogTitle,
|
||||
N8nIconButton,
|
||||
N8nInlineTextEdit,
|
||||
N8nMenuItem,
|
||||
N8nSpinner,
|
||||
N8nText,
|
||||
type IMenuItem,
|
||||
} from '@n8n/design-system';
|
||||
@@ -93,6 +96,7 @@ const settingsStore = useSettingsStore();
|
||||
const uiStore = useUIStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const externalSecretsStore = useExternalSecretsStore();
|
||||
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
|
||||
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const externalHooks = useExternalHooks();
|
||||
@@ -166,6 +170,7 @@ const { getQuickConnectOption, connect: quickConnect } = useQuickConnect();
|
||||
const isQuickConnectMode = ref(false);
|
||||
const activeTab = ref('connection');
|
||||
const modalBus = ref(createEventBus());
|
||||
const closing = ref(false);
|
||||
const isDeleting = ref(false);
|
||||
const hasUnsavedChanges = ref(false);
|
||||
const credentialDescription = ref('');
|
||||
@@ -656,8 +661,20 @@ function onDataChange(update: IUpdateInformation) {
|
||||
if (form.onDataChange(update)) hasUnsavedChanges.value = true;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
modalBus.value.emit('close');
|
||||
async function closeDialog() {
|
||||
if (closing.value) return;
|
||||
closing.value = true;
|
||||
try {
|
||||
if ((await beforeClose()) === false) return;
|
||||
uiStore.closeModal(props.modalName);
|
||||
modalBus.value.emit('closed');
|
||||
} finally {
|
||||
closing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDialogOpenUpdate(open: boolean) {
|
||||
if (!open) void closeDialog();
|
||||
}
|
||||
|
||||
function onNameEdit(text: string) {
|
||||
@@ -1382,172 +1399,177 @@ const { width } = useElementSize(credNameRef);
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Modal
|
||||
:name="modalName"
|
||||
:custom-class="$style.credentialModal"
|
||||
:event-bus="modalBus"
|
||||
:loading="loading"
|
||||
:before-close="beforeClose"
|
||||
width="70%"
|
||||
height="80%"
|
||||
:append-to-body="appendToBody"
|
||||
<N8nDialog
|
||||
:open="modalOpen"
|
||||
size="fit"
|
||||
:stacked="appendToBody"
|
||||
:aria-label="loading ? i18n.baseText('credentials.heading') : undefined"
|
||||
@update:open="onDialogOpenUpdate"
|
||||
>
|
||||
<template #header>
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.credInfo">
|
||||
<div :class="$style.credIcon">
|
||||
<CredentialIcon :credential-type-name="defaultCredentialTypeName" />
|
||||
</div>
|
||||
<div ref="credNameRef" :class="$style.credName">
|
||||
<div :class="$style.credNameRow">
|
||||
<N8nInlineTextEdit
|
||||
v-if="credentialName"
|
||||
data-test-id="credential-name"
|
||||
:model-value="credentialName"
|
||||
:max-width="width - 10"
|
||||
:readonly="
|
||||
!(
|
||||
(credentialPermissions.create && props.mode === 'new') ||
|
||||
credentialPermissions.update
|
||||
) ||
|
||||
!credentialType ||
|
||||
isEditingManagedCredential
|
||||
"
|
||||
@update:model-value="onNameEdit"
|
||||
/>
|
||||
<span
|
||||
v-if="isResolvable"
|
||||
:class="$style.dynamicTag"
|
||||
data-test-id="credential-dynamic-tag"
|
||||
>
|
||||
<PrivateCredentialIcon
|
||||
:tooltip-title="i18n.baseText('credentials.private.tooltipTitle')"
|
||||
:tooltip-text="i18n.baseText('credentials.private.tooltip')"
|
||||
/>
|
||||
</span>
|
||||
<div :class="$style.credentialDialog" data-test-id="editCredential-modal">
|
||||
<template v-if="!loading">
|
||||
<N8nDialogHeader :class="$style.header">
|
||||
<N8nDialogTitle as-child>
|
||||
<div :class="$style.credInfo">
|
||||
<div :class="$style.credIcon">
|
||||
<CredentialIcon :credential-type-name="defaultCredentialTypeName" />
|
||||
</div>
|
||||
<div ref="credNameRef" :class="$style.credName">
|
||||
<div :class="$style.credNameRow">
|
||||
<N8nInlineTextEdit
|
||||
v-if="credentialName"
|
||||
data-test-id="credential-name"
|
||||
:model-value="credentialName"
|
||||
:max-width="width - 10"
|
||||
:readonly="
|
||||
!(
|
||||
(credentialPermissions.create && props.mode === 'new') ||
|
||||
credentialPermissions.update
|
||||
) ||
|
||||
!credentialType ||
|
||||
isEditingManagedCredential
|
||||
"
|
||||
@update:model-value="onNameEdit"
|
||||
/>
|
||||
<span
|
||||
v-if="isResolvable"
|
||||
:class="$style.dynamicTag"
|
||||
data-test-id="credential-dynamic-tag"
|
||||
>
|
||||
<PrivateCredentialIcon
|
||||
:tooltip-title="i18n.baseText('credentials.private.tooltipTitle')"
|
||||
:tooltip-text="i18n.baseText('credentials.private.tooltip')"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<N8nText v-if="credentialType" size="small" tag="p" color="text-light">{{
|
||||
credentialType.displayName
|
||||
}}</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
<N8nText v-if="credentialType" size="small" tag="p" color="text-light">{{
|
||||
credentialType.displayName
|
||||
}}</N8nText>
|
||||
</N8nDialogTitle>
|
||||
<div :class="$style.credActions">
|
||||
<SaveButton
|
||||
v-if="showHeaderSaveButton"
|
||||
:class="$style.saveButton"
|
||||
:disabled="
|
||||
(!isNewCredential && !hasUnsavedChanges && !isTesting) ||
|
||||
!requiredPropertiesFilled
|
||||
"
|
||||
:variant="hasUnsavedChanges || isTesting ? 'solid' : 'subtle'"
|
||||
:is-saving="isSaving || isTesting"
|
||||
:saved="!isNewCredential && isSaved && !hasUnsavedChanges && !isTesting"
|
||||
:saving-label="
|
||||
isTesting
|
||||
? i18n.baseText('credentialEdit.credentialEdit.testing')
|
||||
: i18n.baseText('credentialEdit.credentialEdit.saving')
|
||||
"
|
||||
data-test-id="credential-save-button"
|
||||
@click="saveCredential"
|
||||
/>
|
||||
<N8nIconButton
|
||||
variant="subtle"
|
||||
v-if="
|
||||
currentCredential &&
|
||||
credentialPermissions.delete &&
|
||||
(!isResolvable || credentialPermissions.createEndUser)
|
||||
"
|
||||
:title="i18n.baseText('credentialEdit.credentialEdit.delete')"
|
||||
icon="trash-2"
|
||||
:disabled="isSaving"
|
||||
:loading="isDeleting"
|
||||
data-test-id="credential-delete-button"
|
||||
@click="deleteCredential"
|
||||
/>
|
||||
</div>
|
||||
</N8nDialogHeader>
|
||||
<div :class="$style.container" data-test-id="credential-edit-dialog">
|
||||
<div
|
||||
v-if="credentialDescriptionsEnabled || !isEditingManagedCredential"
|
||||
:class="$style.sidebar"
|
||||
>
|
||||
<N8nMenuItem
|
||||
v-for="item in sidebarItems"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:active="activeTab === item.id"
|
||||
@click="() => onTabSelect(item.id)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeTab === 'connection' && credentialType"
|
||||
ref="contentRef"
|
||||
:class="$style.mainContent"
|
||||
>
|
||||
<CredentialConfig
|
||||
:credential-type="credentialType"
|
||||
:credential-properties="credentialProperties"
|
||||
:credential-data="credentialData"
|
||||
:credential-id="credentialId"
|
||||
:is-managed="isEditingManagedCredential"
|
||||
:show-validation-warning="showValidationWarning"
|
||||
:auth-error="authError"
|
||||
:tested-successfully="testedSuccessfully"
|
||||
:is-o-auth-type="isOAuthType"
|
||||
:is-o-auth-connected="isOAuthConnected"
|
||||
:is-retesting="isRetesting"
|
||||
:parent-types="parentTypes"
|
||||
:required-properties-filled="requiredPropertiesFilled"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:mode="mode"
|
||||
:selected-credential="selectedCredential"
|
||||
:is-private-credentials-enabled="
|
||||
isPrivateCredentialsEnabled && !isInstanceCredential
|
||||
"
|
||||
:is-resolvable="isResolvable"
|
||||
:connected-by-me="connectedByMe"
|
||||
:connected-account-identifier="connectedAccountIdentifier"
|
||||
:is-new-credential="isNewCredential"
|
||||
:new-credential-project-type="homeProject?.type"
|
||||
:managed-oauth-available="managedOAuthAvailable"
|
||||
:use-custom-oauth="useCustomOAuth"
|
||||
:is-quick-connect-mode="isQuickConnectMode"
|
||||
:context-node="contextNode"
|
||||
:hide-ask-assistant="hideAskAssistant"
|
||||
:instance-ai-credential-help="instanceAiCredentialHelp"
|
||||
@update="onDataChange"
|
||||
@oauth="oAuthCredentialAuthorize"
|
||||
@disconnect="onDisconnectMyConnection"
|
||||
@quick-connect="onQuickConnect"
|
||||
@retest="retestCredential"
|
||||
@scroll-to-top="scrollToTop"
|
||||
@auth-type-changed="onAuthTypeChanged"
|
||||
@claimed="closeDialog"
|
||||
@update:is-resolvable="onResolvableChange"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="showSharingContent" :class="$style.mainContent">
|
||||
<CredentialSharing
|
||||
:credential="currentCredential"
|
||||
:credential-data="credentialData"
|
||||
:credential-id="credentialId"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:is-shared-globally="isSharedGlobally"
|
||||
:modal-bus="modalBus"
|
||||
@update:model-value="onChangeSharedWith"
|
||||
@update:share-with-all-users="onShareWithAllUsersUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="activeTab === 'details' && credentialType" :class="$style.mainContent">
|
||||
<CredentialInfo
|
||||
:current-credential="currentCredential"
|
||||
:description="credentialDescription"
|
||||
:readonly="!canEditDescription"
|
||||
@update:description="onDescriptionEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.credActions">
|
||||
<SaveButton
|
||||
v-if="showHeaderSaveButton"
|
||||
:class="$style.saveButton"
|
||||
:disabled="
|
||||
(!isNewCredential && !hasUnsavedChanges && !isTesting) || !requiredPropertiesFilled
|
||||
"
|
||||
:variant="hasUnsavedChanges || isTesting ? 'solid' : 'subtle'"
|
||||
:is-saving="isSaving || isTesting"
|
||||
:saved="!isNewCredential && isSaved && !hasUnsavedChanges && !isTesting"
|
||||
:saving-label="
|
||||
isTesting
|
||||
? i18n.baseText('credentialEdit.credentialEdit.testing')
|
||||
: i18n.baseText('credentialEdit.credentialEdit.saving')
|
||||
"
|
||||
data-test-id="credential-save-button"
|
||||
@click="saveCredential"
|
||||
/>
|
||||
<N8nIconButton
|
||||
variant="subtle"
|
||||
v-if="
|
||||
currentCredential &&
|
||||
credentialPermissions.delete &&
|
||||
(!isResolvable || credentialPermissions.createEndUser)
|
||||
"
|
||||
:title="i18n.baseText('credentialEdit.credentialEdit.delete')"
|
||||
icon="trash-2"
|
||||
:disabled="isSaving"
|
||||
:loading="isDeleting"
|
||||
data-test-id="credential-delete-button"
|
||||
@click="deleteCredential"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else :class="$style.loader">
|
||||
<N8nSpinner />
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.container" data-test-id="credential-edit-dialog">
|
||||
<div
|
||||
v-if="credentialDescriptionsEnabled || !isEditingManagedCredential"
|
||||
:class="$style.sidebar"
|
||||
>
|
||||
<N8nMenuItem
|
||||
v-for="item in sidebarItems"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:active="activeTab === item.id"
|
||||
@click="() => onTabSelect(item.id)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeTab === 'connection' && credentialType"
|
||||
ref="contentRef"
|
||||
:class="$style.mainContent"
|
||||
>
|
||||
<CredentialConfig
|
||||
:credential-type="credentialType"
|
||||
:credential-properties="credentialProperties"
|
||||
:credential-data="credentialData"
|
||||
:credential-id="credentialId"
|
||||
:is-managed="isEditingManagedCredential"
|
||||
:show-validation-warning="showValidationWarning"
|
||||
:auth-error="authError"
|
||||
:tested-successfully="testedSuccessfully"
|
||||
:is-o-auth-type="isOAuthType"
|
||||
:is-o-auth-connected="isOAuthConnected"
|
||||
:is-retesting="isRetesting"
|
||||
:parent-types="parentTypes"
|
||||
:required-properties-filled="requiredPropertiesFilled"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:mode="mode"
|
||||
:selected-credential="selectedCredential"
|
||||
:is-private-credentials-enabled="isPrivateCredentialsEnabled && !isInstanceCredential"
|
||||
:is-resolvable="isResolvable"
|
||||
:connected-by-me="connectedByMe"
|
||||
:connected-account-identifier="connectedAccountIdentifier"
|
||||
:is-new-credential="isNewCredential"
|
||||
:new-credential-project-type="homeProject?.type"
|
||||
:managed-oauth-available="managedOAuthAvailable"
|
||||
:use-custom-oauth="useCustomOAuth"
|
||||
:is-quick-connect-mode="isQuickConnectMode"
|
||||
:context-node="contextNode"
|
||||
:hide-ask-assistant="hideAskAssistant"
|
||||
:instance-ai-credential-help="instanceAiCredentialHelp"
|
||||
@update="onDataChange"
|
||||
@oauth="oAuthCredentialAuthorize"
|
||||
@disconnect="onDisconnectMyConnection"
|
||||
@quick-connect="onQuickConnect"
|
||||
@retest="retestCredential"
|
||||
@scroll-to-top="scrollToTop"
|
||||
@auth-type-changed="onAuthTypeChanged"
|
||||
@claimed="closeDialog"
|
||||
@update:is-resolvable="onResolvableChange"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="showSharingContent" :class="$style.mainContent">
|
||||
<CredentialSharing
|
||||
:credential="currentCredential"
|
||||
:credential-data="credentialData"
|
||||
:credential-id="credentialId"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:is-shared-globally="isSharedGlobally"
|
||||
:modal-bus="modalBus"
|
||||
@update:model-value="onChangeSharedWith"
|
||||
@update:share-with-all-users="onShareWithAllUsersUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="activeTab === 'details' && credentialType" :class="$style.mainContent">
|
||||
<CredentialInfo
|
||||
:current-credential="currentCredential"
|
||||
:description="credentialDescription"
|
||||
:readonly="!canEditDescription"
|
||||
@update:description="onDescriptionEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</N8nDialog>
|
||||
<TypeToConfirmDialog
|
||||
v-if="typeToConfirmDialog"
|
||||
:open="typeToConfirmDialog.open"
|
||||
@@ -1563,20 +1585,13 @@ const { width } = useElementSize(credNameRef);
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.credentialModal {
|
||||
--dialog--max-width: 1200px;
|
||||
--dialog--close--spacing--top: 36px;
|
||||
--dialog--max-height: 750px;
|
||||
|
||||
:global(.el-dialog__header) {
|
||||
padding-bottom: 0;
|
||||
border-bottom: var(--border);
|
||||
}
|
||||
|
||||
:global(.el-dialog__body) {
|
||||
padding-top: var(--spacing--lg);
|
||||
position: relative;
|
||||
}
|
||||
.credentialDialog {
|
||||
width: min(70dvw, calc(var(--spacing--5xl) * 4 + var(--spacing--4xl) + var(--spacing--2xl)));
|
||||
height: 80dvh;
|
||||
max-height: calc(var(--height--5xl) * 8);
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
@@ -1618,11 +1633,19 @@ const { width } = useElementSize(credNameRef);
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin: calc(var(--spacing--lg) * -1) calc(var(--spacing--lg) * -1) 0;
|
||||
padding: var(--spacing--md) var(--spacing--lg);
|
||||
border-bottom: var(--border);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-top: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.credInfo {
|
||||
@@ -1630,7 +1653,6 @@ const { width } = useElementSize(credNameRef);
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
margin-bottom: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.credActions {
|
||||
@@ -1639,10 +1661,17 @@ const { width } = useElementSize(credNameRef);
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
margin-right: var(--spacing--xl);
|
||||
margin-bottom: var(--spacing--lg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: var(--color--primary--tint-1);
|
||||
}
|
||||
|
||||
.credIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1653,4 +1682,16 @@ const { width } = useElementSize(credNameRef);
|
||||
flex-shrink: 0;
|
||||
min-width: 57px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.credentialDialog {
|
||||
width: calc(100dvw - var(--spacing--3xl));
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
min-width: calc(var(--spacing--5xl) + var(--spacing--2xl));
|
||||
max-width: calc(var(--spacing--5xl) + var(--spacing--2xl));
|
||||
margin-right: var(--spacing--sm);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
@@ -189,6 +189,7 @@ function goToUpgrade() {
|
||||
:placeholder="sharingSelectPlaceholder"
|
||||
:can-share-globally="canShareGlobally"
|
||||
:is-shared-globally="isSharedGlobally"
|
||||
:teleported="false"
|
||||
@update:share-with-all-users="emit('update:shareWithAllUsers', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ function close(): void {
|
||||
:open="open"
|
||||
:header="title"
|
||||
size="small"
|
||||
stacked
|
||||
data-test-id="credential-type-to-confirm-dialog"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
|
||||
@@ -14,6 +14,10 @@ import { resolveToolItemIcon } from './toolItemIcon';
|
||||
|
||||
const props = defineProps<{
|
||||
item: ToolConnectionItem;
|
||||
showConnectAction?: boolean;
|
||||
connectLabel?: string;
|
||||
connectAriaLabel?: string;
|
||||
connectedLabel?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -78,15 +82,13 @@ const placeholderIcon = computed(() => {
|
||||
|
||||
const resolvedIcon = computed(() => resolveToolItemIcon(props.item));
|
||||
|
||||
const actionLabel = computed(() =>
|
||||
props.item.communityPreview
|
||||
? i18n.baseText('communityNodeDetails.install')
|
||||
: i18n.baseText(
|
||||
props.item.status === 'disconnected'
|
||||
? 'tools.connection.action.reconnect'
|
||||
: 'tools.connection.action.connect',
|
||||
),
|
||||
);
|
||||
const actionLabel = computed(() => {
|
||||
if (props.item.communityPreview) return i18n.baseText('communityNodeDetails.install');
|
||||
if (props.item.status === 'disconnected') {
|
||||
return i18n.baseText('tools.connection.action.reconnect');
|
||||
}
|
||||
return props.connectLabel ?? i18n.baseText('tools.connection.action.connect');
|
||||
});
|
||||
|
||||
const installBlocked = computed(
|
||||
() => Boolean(props.item.communityPreview) && Boolean(props.item.installDisabled),
|
||||
@@ -101,7 +103,10 @@ const isDisabled = computed(() => Boolean(props.item.disabled));
|
||||
* its detail view.
|
||||
*/
|
||||
const hasDirectAction = computed(
|
||||
() => Boolean(props.item.communityPreview) || props.item.kind === 'mcp-server',
|
||||
() =>
|
||||
props.showConnectAction ||
|
||||
Boolean(props.item.communityPreview) ||
|
||||
props.item.kind === 'mcp-server',
|
||||
);
|
||||
|
||||
function handleRowClick() {
|
||||
@@ -224,7 +229,7 @@ function handleConnect() {
|
||||
data-test-id="tools-connection-row-connected"
|
||||
>
|
||||
<N8nIcon icon="check" :size="14" :class="$style.statusIconConnected" aria-hidden="true" />
|
||||
{{ i18n.baseText('tools.connection.action.connected') }}
|
||||
{{ connectedLabel ?? i18n.baseText('tools.connection.action.connected') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.status === 'connecting'"
|
||||
@@ -255,6 +260,7 @@ function handleConnect() {
|
||||
variant="outline"
|
||||
size="small"
|
||||
:loading="item.installing"
|
||||
:aria-label="connectAriaLabel"
|
||||
:data-test-id="
|
||||
item.communityPreview ? 'tools-connection-row-install' : 'tools-connection-row-connect'
|
||||
"
|
||||
|
||||
+97
-37
@@ -26,6 +26,13 @@ import {
|
||||
type ToolConnectionSettings,
|
||||
} from './types';
|
||||
|
||||
type PickerCreateAction = {
|
||||
category: ToolCategoryKey;
|
||||
label: string;
|
||||
description?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open?: boolean;
|
||||
@@ -39,16 +46,31 @@ const props = withDefaults(
|
||||
hideBackButton?: boolean;
|
||||
/** Dialog width. Consumers with more tabs (e.g. the n8n Connect section) can widen it. */
|
||||
size?: DialogSize;
|
||||
allowWorkflowCreation?: boolean;
|
||||
workflowCreationLoading?: boolean;
|
||||
createAction?: PickerCreateAction;
|
||||
createActionLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
noResultsMessage?: string;
|
||||
/** Render only the modal body when an owning feature supplies the dialog shell. */
|
||||
embedded?: boolean;
|
||||
showConnectActions?: boolean;
|
||||
/** Keep the list scrollbar visible instead of revealing it on hover. */
|
||||
persistentScrollbar?: boolean;
|
||||
connectLabel?: (item: ToolConnectionItem) => string;
|
||||
connectAriaLabel?: (item: ToolConnectionItem) => string;
|
||||
connectedLabel?: (item: ToolConnectionItem) => string;
|
||||
}>(),
|
||||
{
|
||||
open: false,
|
||||
detailItem: null,
|
||||
detailMode: 'detail',
|
||||
size: 'xlarge',
|
||||
allowWorkflowCreation: false,
|
||||
workflowCreationLoading: false,
|
||||
createAction: undefined,
|
||||
createActionLoading: false,
|
||||
emptyMessage: undefined,
|
||||
noResultsMessage: undefined,
|
||||
embedded: false,
|
||||
showConnectActions: false,
|
||||
persistentScrollbar: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -64,11 +86,23 @@ const emit = defineEmits<{
|
||||
'new-credential-connect': [item: ToolConnectionItem];
|
||||
'open-detail': [item: ToolConnectionItem];
|
||||
connect: [item: ToolConnectionItem];
|
||||
'create-workflow': [];
|
||||
create: [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const modalTitle = computed(() => props.title ?? i18n.baseText('tools.connection.title'));
|
||||
const containerComponent = computed(() => (props.embedded ? 'div' : N8nDialog));
|
||||
const containerProps = computed(() =>
|
||||
props.embedded
|
||||
? {}
|
||||
: {
|
||||
open: props.open,
|
||||
size: props.size,
|
||||
header: props.detailItem ? '' : modalTitle.value,
|
||||
showCloseButton: !props.detailItem,
|
||||
'aria-label': modalTitle.value,
|
||||
},
|
||||
);
|
||||
const searchPlaceholder = computed(
|
||||
() => props.searchPlaceholder ?? i18n.baseText('tools.connection.search.placeholder'),
|
||||
);
|
||||
@@ -124,11 +158,12 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const hasActiveSearch = computed(() => debouncedSearchQuery.value.length > 0);
|
||||
const normalizedSearchQuery = computed(() => debouncedSearchQuery.value.trim());
|
||||
const hasActiveSearch = computed(() => normalizedSearchQuery.value.length > 0);
|
||||
|
||||
function matchesQuery(item: ToolConnectionItem): boolean {
|
||||
if (!debouncedSearchQuery.value) return true;
|
||||
const query = debouncedSearchQuery.value.toLowerCase();
|
||||
if (!normalizedSearchQuery.value) return true;
|
||||
const query = normalizedSearchQuery.value.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(query) ||
|
||||
(item.description ?? '').toLowerCase().includes(query)
|
||||
@@ -260,14 +295,16 @@ watch(visibleCategories, (categories) => {
|
||||
});
|
||||
|
||||
const isListEmpty = computed(() => toolRows.value.length === 0);
|
||||
const emptyMessage = computed(() => {
|
||||
const resolvedEmptyMessage = computed(() => {
|
||||
if (hasActiveSearch.value) {
|
||||
if (props.noResultsMessage) return props.noResultsMessage;
|
||||
return i18n.baseText('tools.connection.empty.noResults', {
|
||||
interpolate: { query: debouncedSearchQuery.value },
|
||||
interpolate: { query: normalizedSearchQuery.value },
|
||||
});
|
||||
}
|
||||
return i18n.baseText('tools.connection.empty.title');
|
||||
return props.emptyMessage ?? i18n.baseText('tools.connection.empty.title');
|
||||
});
|
||||
const showCreateAction = computed(() => props.createAction?.category === activeCategory.value);
|
||||
|
||||
function openDetail(item: ToolConnectionItem) {
|
||||
emit('open-detail', item);
|
||||
@@ -287,12 +324,10 @@ function handleOpenChange(value: boolean) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nDialog
|
||||
:open="open"
|
||||
:size="size"
|
||||
:header="detailItem ? '' : modalTitle"
|
||||
:show-close-button="!detailItem"
|
||||
:aria-label="modalTitle"
|
||||
<component
|
||||
:is="containerComponent"
|
||||
v-bind="containerProps"
|
||||
:class="props.embedded && $style.embedded"
|
||||
data-test-id="tools-connection-modal"
|
||||
@update:open="handleOpenChange"
|
||||
>
|
||||
@@ -361,27 +396,27 @@ function handleOpenChange(value: boolean) {
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="activeCategory === 'workflows' && allowWorkflowCreation"
|
||||
v-if="showCreateAction && createAction"
|
||||
type="button"
|
||||
:class="$style.createWorkflowRow"
|
||||
:disabled="workflowCreationLoading"
|
||||
:aria-busy="workflowCreationLoading"
|
||||
data-test-id="tools-connection-create-workflow"
|
||||
@click="emit('create-workflow')"
|
||||
:class="$style.createRow"
|
||||
:disabled="createActionLoading"
|
||||
:aria-busy="createActionLoading"
|
||||
:data-test-id="createAction.testId ?? 'tools-connection-create'"
|
||||
@click="emit('create')"
|
||||
>
|
||||
<span :class="$style.createWorkflowIcon" aria-hidden="true">
|
||||
<span :class="$style.createIcon" aria-hidden="true">
|
||||
<N8nIcon
|
||||
:icon="workflowCreationLoading ? 'loader-circle' : 'plus'"
|
||||
:icon="createActionLoading ? 'loader-circle' : 'plus'"
|
||||
:size="20"
|
||||
:spin="workflowCreationLoading"
|
||||
:spin="createActionLoading"
|
||||
/>
|
||||
</span>
|
||||
<span :class="$style.createWorkflowText">
|
||||
<span :class="$style.createText">
|
||||
<N8nText tag="span" bold>
|
||||
{{ i18n.baseText('generic.create.workflow') }}
|
||||
{{ createAction.label }}
|
||||
</N8nText>
|
||||
<N8nText tag="span" size="small" color="text-light">
|
||||
{{ i18n.baseText('projectRoles.workflow:create.tooltip') }}
|
||||
<N8nText v-if="createAction.description" tag="span" size="small" color="text-light">
|
||||
{{ createAction.description }}
|
||||
</N8nText>
|
||||
</span>
|
||||
</button>
|
||||
@@ -389,7 +424,7 @@ function handleOpenChange(value: boolean) {
|
||||
<div :class="$style.listWrapper">
|
||||
<template v-if="isListEmpty">
|
||||
<div :class="$style.empty" data-test-id="tools-connection-empty">
|
||||
<N8nText color="text-light">{{ emptyMessage }}</N8nText>
|
||||
<N8nText color="text-light">{{ resolvedEmptyMessage }}</N8nText>
|
||||
</div>
|
||||
<div v-if="isMcpCategory" :class="$style.suggestionRow">
|
||||
<slot name="suggestion-footer" />
|
||||
@@ -401,12 +436,16 @@ function handleOpenChange(value: boolean) {
|
||||
:items="flattenedRows"
|
||||
:item-size="ITEM_HEIGHT"
|
||||
item-key="key"
|
||||
:class="$style.scroller"
|
||||
:class="[$style.scroller, persistentScrollbar && $style.persistentScrollbar]"
|
||||
>
|
||||
<template #default="{ item: row }">
|
||||
<ToolRow
|
||||
v-if="'item' in row"
|
||||
:item="row.item"
|
||||
:show-connect-action="props.showConnectActions"
|
||||
:connect-label="props.connectLabel?.(row.item)"
|
||||
:connect-aria-label="props.connectAriaLabel?.(row.item)"
|
||||
:connected-label="props.connectedLabel?.(row.item)"
|
||||
@open-detail="openDetail($event)"
|
||||
@connect="emit('connect', $event)"
|
||||
@select-credential="
|
||||
@@ -425,18 +464,35 @@ function handleOpenChange(value: boolean) {
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</N8nDialog>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '@n8n/design-system/css/mixins/mixins' as scrollbar-mixins;
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70vh;
|
||||
max-height: 640px;
|
||||
max-height: calc(var(--height--5xl) * 6);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.embedded {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
.body {
|
||||
height: min(60dvh, calc(var(--height--5xl) * 5));
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--spacing--lg);
|
||||
}
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
@@ -451,7 +507,7 @@ function handleOpenChange(value: boolean) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.createWorkflowRow {
|
||||
.createRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
@@ -480,7 +536,7 @@ function handleOpenChange(value: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
.createWorkflowIcon {
|
||||
.createIcon {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -490,7 +546,7 @@ function handleOpenChange(value: boolean) {
|
||||
color: var(--color--primary);
|
||||
}
|
||||
|
||||
.createWorkflowText {
|
||||
.createText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
@@ -511,6 +567,10 @@ function handleOpenChange(value: boolean) {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.persistentScrollbar {
|
||||
@include scrollbar-mixins.scroll-bar;
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
+23
-4
@@ -91,7 +91,11 @@ function renderWith(
|
||||
categories: ToolCategoryKey[];
|
||||
detailItem: ToolConnectionItem | null;
|
||||
detailMode: 'detail' | 'settings';
|
||||
allowWorkflowCreation: boolean;
|
||||
createAction: {
|
||||
category: ToolCategoryKey;
|
||||
label: string;
|
||||
testId?: string;
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
return renderModal({
|
||||
@@ -101,7 +105,7 @@ function renderWith(
|
||||
categories: props.categories ?? ALL_CATEGORIES,
|
||||
detailItem: props.detailItem ?? null,
|
||||
detailMode: props.detailMode,
|
||||
allowWorkflowCreation: props.allowWorkflowCreation,
|
||||
createAction: props.createAction,
|
||||
},
|
||||
slots: {
|
||||
'suggestion-footer': '<div data-test-id="suggest-tool-footer">Suggest a tool</div>',
|
||||
@@ -258,7 +262,11 @@ describe('ToolsConnectionModal', () => {
|
||||
const { emitted, getByTestId, queryByTestId } = renderWith({
|
||||
items: [],
|
||||
categories: ['mcp', 'workflows'],
|
||||
allowWorkflowCreation: true,
|
||||
createAction: {
|
||||
category: 'workflows',
|
||||
label: 'Create workflow',
|
||||
testId: 'tools-connection-create-workflow',
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('tools-connection-create-workflow')).toBeNull();
|
||||
@@ -267,7 +275,7 @@ describe('ToolsConnectionModal', () => {
|
||||
expect(getByTestId('tools-connection-empty')).toBeTruthy();
|
||||
|
||||
await fireEvent.click(getByTestId('tools-connection-create-workflow'));
|
||||
expect(emitted()['create-workflow']).toEqual([[]]);
|
||||
expect(emitted().create).toEqual([[]]);
|
||||
});
|
||||
|
||||
it('renders the detail view when a detailItem is set', () => {
|
||||
@@ -503,6 +511,17 @@ describe('ToolsConnectionModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores surrounding whitespace in a search query', async () => {
|
||||
const { getByPlaceholderText, queryByText } = renderWith({ categories: ['mcp'] });
|
||||
|
||||
await fireEvent.update(getByPlaceholderText('Search all tools...'), ' github ');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('GitHub')).toBeTruthy();
|
||||
expect(queryByText('Gmail')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('feeds every flattened row through to the scroller', async () => {
|
||||
const items = makeLargeMcpList(300);
|
||||
const { getAllByTestId } = renderWith({ items, categories: ['mcp'] });
|
||||
|
||||
@@ -127,9 +127,8 @@ export type ToolConnectionItem =
|
||||
| ServiceConnectionItem;
|
||||
|
||||
/**
|
||||
* One tab in the modal. Consumers declare the subset they support; `agents` and
|
||||
* `data` have no supplier yet and are reserved for folding the sub-agent and
|
||||
* vector-store pickers in later.
|
||||
* One tab in the modal. Consumers declare the subset they support. `data` has
|
||||
* no supplier yet and is reserved for folding the vector-store picker in later.
|
||||
*/
|
||||
export type ToolCategoryKey =
|
||||
| 'all'
|
||||
|
||||
@@ -19,7 +19,8 @@ export class CredentialModal extends BaseModal {
|
||||
}
|
||||
|
||||
static fromPage(page: Page): CredentialModal {
|
||||
return new CredentialModal(page.getByTestId('editCredential-modal'));
|
||||
const content = page.getByTestId('editCredential-modal');
|
||||
return new CredentialModal(page.getByRole('dialog').filter({ has: content }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +34,13 @@ export class CredentialModal extends BaseModal {
|
||||
}
|
||||
|
||||
getModal(): Locator {
|
||||
return this.root;
|
||||
return this.root.getByTestId('editCredential-modal');
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const closeButton = this.getCloseButton();
|
||||
if (!(await closeButton.isVisible())) return;
|
||||
await closeButton.click();
|
||||
}
|
||||
|
||||
getCredentialName(): Locator {
|
||||
|
||||
Reference in New Issue
Block a user