mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(editor): AI workflow builder setup wizard (#26832)
Co-authored-by: Charlie Kolb <charlie@n8n.io> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Charlie Kolb
Claude Opus 4.6
parent
6fcc86037d
commit
2ff73d5440
@@ -334,6 +334,11 @@
|
||||
"aiAssistant.builder.executeMessage.unpinAll": "Unpin all nodes",
|
||||
"aiAssistant.builder.executeMessage.unpinIndividually": "or unpin individually",
|
||||
"aiAssistant.builder.executeMessage.unpinTooltip": "Right-click on a node and select \"Unpin\" to unpin it",
|
||||
"aiAssistant.builder.executeMessage.wizardSetupSuccess": "Workflow set up successfully",
|
||||
"aiAssistant.builder.setupWizard.continue": "Continue",
|
||||
"aiAssistant.builder.setupWizard.complete": "Complete",
|
||||
"aiAssistant.builder.setupWizard.checkingCredentials": "Checking credentials",
|
||||
"aiAssistant.builder.setupWizard.configureParameters": "Configure {count} parameter | Configure {count} parameters",
|
||||
"aiAssistant.builder.toast.title": "Send chat message to start the execution",
|
||||
"aiAssistant.builder.toast.description": "Please send a message in the chat panel to start the execution of your workflow",
|
||||
"aiAssistant.builder.restoreError.title": "Failed to restore version",
|
||||
|
||||
@@ -99,6 +99,7 @@ export const CODE_WORKFLOW_BUILDER_EXPERIMENT = createExperiment('071_coding_wor
|
||||
|
||||
export const QUICK_CONNECT_EXPERIMENT = createExperiment('073_quick_connect');
|
||||
|
||||
export const AI_BUILDER_SETUP_WIZARD_EXPERIMENT = createExperiment('079_ai_builder_setup_wizard');
|
||||
export const AA_EXPERIMENT_CHECK = createExperiment('078_experiment_check_aa');
|
||||
|
||||
export const CHAT_HUB_SEMANTIC_SEARCH_EXPERIMENT = createExperiment('077_chat_hub_semantic_search');
|
||||
@@ -127,6 +128,7 @@ export const EXPERIMENTS_TO_TRACK = [
|
||||
QUICK_CONNECT_EXPERIMENT.name,
|
||||
AI_BUILDER_REVIEW_CHANGES_EXPERIMENT.name,
|
||||
MERGE_ASK_BUILD_EXPERIMENT.name,
|
||||
AI_BUILDER_SETUP_WIZARD_EXPERIMENT.name,
|
||||
AA_EXPERIMENT_CHECK.name,
|
||||
CHAT_HUB_SEMANTIC_SEARCH_EXPERIMENT.name,
|
||||
FLOATING_CHAT_HUB_PANEL_EXPERIMENT.name,
|
||||
|
||||
@@ -313,7 +313,7 @@ describe('workflowUtils', () => {
|
||||
connectionsByDestination,
|
||||
);
|
||||
|
||||
expect(result.map((n) => n.node.name)).toEqual(['MCPServer', 'Tool1', 'Tool2']);
|
||||
expect(result.map((n) => n.node.name)).toEqual(['Tool1', 'Tool2', 'MCPServer']);
|
||||
});
|
||||
|
||||
it('should discover AI sub-nodes alongside main downstream nodes', () => {
|
||||
@@ -338,7 +338,153 @@ describe('workflowUtils', () => {
|
||||
connectionsByDestination,
|
||||
);
|
||||
|
||||
expect(result.map((n) => n.node.name)).toEqual(['Agent', 'Downstream', 'Tool']);
|
||||
expect(result.map((n) => n.node.name)).toEqual(['Tool', 'Agent', 'Downstream']);
|
||||
});
|
||||
|
||||
it('should place AI sub-nodes before their parent agent when agent is mid-chain', () => {
|
||||
// Trigger → Agent → Downstream, with LLM and Tool connected to Agent via AI inputs
|
||||
const trigger = makeNode('Start', [240, 400], true);
|
||||
const llm = makeNode('GPT Model', [704, 624]);
|
||||
const tool = makeNode('Google Search', [832, 624]);
|
||||
const agent = makeNode('Research Agent', [688, 400]);
|
||||
const downstream = makeNode('Send Report Email', [2096, 400]);
|
||||
|
||||
const connectionsBySource = {
|
||||
Start: { main: [[{ node: 'Research Agent', type: 'main' as const, index: 0 }]] },
|
||||
'Research Agent': {
|
||||
main: [[{ node: 'Send Report Email', type: 'main' as const, index: 0 }]],
|
||||
},
|
||||
'GPT Model': {
|
||||
ai_languageModel: [
|
||||
[{ node: 'Research Agent', type: 'ai_languageModel' as const, index: 0 }],
|
||||
],
|
||||
},
|
||||
'Google Search': {
|
||||
ai_tool: [[{ node: 'Research Agent', type: 'ai_tool' as const, index: 0 }]],
|
||||
},
|
||||
};
|
||||
|
||||
const connectionsByDestination = {
|
||||
'Research Agent': {
|
||||
main: [[{ node: 'Start', type: 'main' as const, index: 0 }]],
|
||||
ai_languageModel: [[{ node: 'GPT Model', type: 'ai_languageModel' as const, index: 0 }]],
|
||||
ai_tool: [[{ node: 'Google Search', type: 'ai_tool' as const, index: 0 }]],
|
||||
},
|
||||
'Send Report Email': {
|
||||
main: [[{ node: 'Research Agent', type: 'main' as const, index: 0 }]],
|
||||
},
|
||||
};
|
||||
|
||||
const result = sortNodesByExecutionOrder(
|
||||
[downstream, llm, tool, agent, trigger],
|
||||
connectionsBySource,
|
||||
connectionsByDestination,
|
||||
);
|
||||
|
||||
// Sub-nodes (GPT Model, Google Search) should appear before their parent (Research Agent)
|
||||
// and the parent should appear before downstream nodes (Send Report Email)
|
||||
expect(result.map((n) => n.node.name)).toEqual([
|
||||
'Start',
|
||||
'GPT Model',
|
||||
'Google Search',
|
||||
'Research Agent',
|
||||
'Send Report Email',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle shared sub-nodes connected to multiple agents in a chain', () => {
|
||||
// Start → Research Agent → Fact-Checking Agent → Report Writing Agent → HTML Agent → Send Email
|
||||
// GPT Model is shared across ALL four agents (ai_languageModel)
|
||||
// Google Search is shared across Research Agent and Fact-Checking Agent (ai_tool)
|
||||
// Only Start, Research Agent, GPT Model, Google Search, Send Email are in the nodes input
|
||||
const start = makeNode('Start', [240, 304], true);
|
||||
const researchAgent = makeNode('Research Agent', [464, 304]);
|
||||
const gptModel = makeNode('GPT-4.1 Mini Model', [480, 528]);
|
||||
const googleSearch = makeNode('Google Search', [608, 528]);
|
||||
const sendEmail = makeNode('Send Report Email', [1872, 304]);
|
||||
|
||||
const connectionsBySource = {
|
||||
Start: { main: [[{ node: 'Research Agent', type: 'main' as const, index: 0 }]] },
|
||||
'Research Agent': {
|
||||
main: [[{ node: 'Fact-Checking Agent', type: 'main' as const, index: 0 }]],
|
||||
},
|
||||
'GPT-4.1 Mini Model': {
|
||||
ai_languageModel: [
|
||||
[
|
||||
{ node: 'Research Agent', type: 'ai_languageModel' as const, index: 0 },
|
||||
{ node: 'Fact-Checking Agent', type: 'ai_languageModel' as const, index: 0 },
|
||||
{ node: 'Report Writing Agent', type: 'ai_languageModel' as const, index: 0 },
|
||||
{ node: 'HTML Formatting Agent', type: 'ai_languageModel' as const, index: 0 },
|
||||
],
|
||||
],
|
||||
},
|
||||
'Google Search': {
|
||||
ai_tool: [
|
||||
[
|
||||
{ node: 'Research Agent', type: 'ai_tool' as const, index: 0 },
|
||||
{ node: 'Fact-Checking Agent', type: 'ai_tool' as const, index: 0 },
|
||||
],
|
||||
],
|
||||
},
|
||||
'Fact-Checking Agent': {
|
||||
main: [[{ node: 'Report Writing Agent', type: 'main' as const, index: 0 }]],
|
||||
},
|
||||
'Report Writing Agent': {
|
||||
main: [[{ node: 'HTML Formatting Agent', type: 'main' as const, index: 0 }]],
|
||||
},
|
||||
'HTML Formatting Agent': {
|
||||
main: [[{ node: 'Send Report Email', type: 'main' as const, index: 0 }]],
|
||||
},
|
||||
};
|
||||
|
||||
const connectionsByDestination = {
|
||||
'Research Agent': {
|
||||
main: [[{ node: 'Start', type: 'main' as const, index: 0 }]],
|
||||
ai_languageModel: [
|
||||
[{ node: 'GPT-4.1 Mini Model', type: 'ai_languageModel' as const, index: 0 }],
|
||||
],
|
||||
ai_tool: [[{ node: 'Google Search', type: 'ai_tool' as const, index: 0 }]],
|
||||
},
|
||||
'Fact-Checking Agent': {
|
||||
main: [[{ node: 'Research Agent', type: 'main' as const, index: 0 }]],
|
||||
ai_languageModel: [
|
||||
[{ node: 'GPT-4.1 Mini Model', type: 'ai_languageModel' as const, index: 0 }],
|
||||
],
|
||||
ai_tool: [[{ node: 'Google Search', type: 'ai_tool' as const, index: 0 }]],
|
||||
},
|
||||
'Report Writing Agent': {
|
||||
main: [[{ node: 'Fact-Checking Agent', type: 'main' as const, index: 0 }]],
|
||||
ai_languageModel: [
|
||||
[{ node: 'GPT-4.1 Mini Model', type: 'ai_languageModel' as const, index: 0 }],
|
||||
],
|
||||
},
|
||||
'HTML Formatting Agent': {
|
||||
main: [[{ node: 'Report Writing Agent', type: 'main' as const, index: 0 }]],
|
||||
ai_languageModel: [
|
||||
[{ node: 'GPT-4.1 Mini Model', type: 'ai_languageModel' as const, index: 0 }],
|
||||
],
|
||||
},
|
||||
'Send Report Email': {
|
||||
main: [[{ node: 'HTML Formatting Agent', type: 'main' as const, index: 0 }]],
|
||||
},
|
||||
};
|
||||
|
||||
const result = sortNodesByExecutionOrder(
|
||||
[sendEmail, gptModel, googleSearch, researchAgent, start],
|
||||
connectionsBySource,
|
||||
connectionsByDestination,
|
||||
);
|
||||
|
||||
// Sub-nodes should appear before their first parent agent (Research Agent),
|
||||
// and the entire main chain should maintain execution order.
|
||||
// Research Agent must come before Send Report Email.
|
||||
expect(result.map((n) => n.node.name)).toEqual([
|
||||
'Start',
|
||||
'GPT-4.1 Mini Model',
|
||||
'Google Search',
|
||||
'Research Agent',
|
||||
'Send Report Email',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not follow main connections from connectionsByDestinationNode', () => {
|
||||
|
||||
@@ -57,6 +57,7 @@ interface ExecutionOrderItem {
|
||||
* AI sub-nodes (tools, memory, etc.) connected via non-main connection types
|
||||
* are discovered through connectionsByDestinationNode, mirroring how the
|
||||
* backend discovers them on-demand via getInputConnectionData().
|
||||
* Sub-nodes are placed before their parent node in the result.
|
||||
*
|
||||
* Orphaned nodes (not reachable from any trigger) are dropped.
|
||||
* When there are no triggers, returns an empty array.
|
||||
@@ -80,50 +81,52 @@ export function sortNodesByExecutionOrder<T extends ExecutionOrderItem>(
|
||||
const result: T[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
const visitNode = (name: string) => {
|
||||
if (visited.has(name)) return;
|
||||
visited.add(name);
|
||||
const addNode = (name: string) => {
|
||||
const item = itemsByName.get(name);
|
||||
if (item) {
|
||||
result.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
// DFS that visits AI sub-nodes before the node itself,
|
||||
// so that sub-nodes (tools, memory, models) appear before their parent (agent).
|
||||
const dfs = (name: string) => {
|
||||
if (visited.has(name)) return;
|
||||
visited.add(name);
|
||||
|
||||
// First, discover AI sub-nodes connected to this node's non-main inputs.
|
||||
// Visit them before adding the current node so sub-nodes appear first.
|
||||
const destConns = connectionsByDestinationNode[name];
|
||||
if (destConns) {
|
||||
for (const type of Object.keys(destConns)) {
|
||||
if (type === 'main') continue;
|
||||
for (const inputs of destConns[type]) {
|
||||
for (const conn of inputs ?? []) {
|
||||
dfs(conn.node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the current node after its sub-nodes
|
||||
addNode(name);
|
||||
|
||||
// Then follow outgoing main connections only.
|
||||
// Non-main outgoing connections (ai_tool, ai_languageModel, etc.) point from
|
||||
// sub-nodes to their parent agents — those are already discovered via
|
||||
// connectionsByDestinationNode. Following them here would traverse the
|
||||
// main chain out of order when sub-nodes are shared across multiple agents.
|
||||
const sourceConns = connectionsBySourceNode[name];
|
||||
if (sourceConns?.main) {
|
||||
for (const outputs of sourceConns.main) {
|
||||
for (const conn of outputs ?? []) {
|
||||
dfs(conn.node);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const trigger of triggers) {
|
||||
visitNode(trigger.node.name);
|
||||
|
||||
// DFS through all workflow connections from this trigger
|
||||
const dfs = (name: string) => {
|
||||
// Follow outgoing connections (main flow + any source-side connections)
|
||||
const sourceConns = connectionsBySourceNode[name];
|
||||
if (sourceConns) {
|
||||
for (const type of Object.keys(sourceConns)) {
|
||||
for (const outputs of sourceConns[type]) {
|
||||
for (const conn of outputs ?? []) {
|
||||
if (visited.has(conn.node)) continue;
|
||||
visitNode(conn.node);
|
||||
dfs(conn.node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discover AI sub-nodes connected to this node's non-main inputs
|
||||
// (e.g. tools, memory, language models connected via ai_tool, ai_memory, etc.)
|
||||
const destConns = connectionsByDestinationNode[name];
|
||||
if (destConns) {
|
||||
for (const type of Object.keys(destConns)) {
|
||||
if (type === 'main') continue;
|
||||
for (const inputs of destConns[type]) {
|
||||
for (const conn of inputs ?? []) {
|
||||
if (visited.has(conn.node)) continue;
|
||||
visitNode(conn.node);
|
||||
dfs(conn.node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
dfs(trigger.node.name);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,10 @@ export type WorkflowBuilderJourneyEventType =
|
||||
| 'user_closed_review_changes'
|
||||
| 'user_expanded_review_changes'
|
||||
| 'user_collapsed_review_changes'
|
||||
| 'setup_wizard_shown'
|
||||
| 'setup_wizard_step_navigated'
|
||||
| 'setup_wizard_step_completed'
|
||||
| 'setup_wizard_all_complete'
|
||||
| 'web_fetch_approval_prompted'
|
||||
| 'web_fetch_decision'
|
||||
| 'web_fetch_completed'
|
||||
@@ -98,6 +102,9 @@ interface WorkflowBuilderJourneyEventProperties {
|
||||
no_versions_reverted?: number;
|
||||
completion_type?: 'workflow-ready' | 'input-needed';
|
||||
mode?: 'plan' | 'build';
|
||||
step?: number;
|
||||
total?: number;
|
||||
direction?: 'next' | 'prev';
|
||||
domain?: string;
|
||||
url?: string;
|
||||
decision?: 'allow_once' | 'allow_domain' | 'allow_all' | 'deny';
|
||||
@@ -161,9 +168,20 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
error: 0,
|
||||
});
|
||||
|
||||
// Track whether a successful full execution has occurred in this session
|
||||
// Track whether any successful execution (full workflow or per-node) has occurred in this session
|
||||
const hasHadSuccessfulExecution = ref(false);
|
||||
|
||||
// Setup wizard state
|
||||
const wizardCurrentStep = ref(0);
|
||||
const wizardClearedPlaceholders = ref(new Set<string>());
|
||||
const wizardHasExecutedWorkflow = ref(false);
|
||||
|
||||
function resetWizardState() {
|
||||
wizardCurrentStep.value = 0;
|
||||
wizardClearedPlaceholders.value.clear();
|
||||
wizardHasExecutedWorkflow.value = false;
|
||||
}
|
||||
|
||||
// Track whether AI Builder made edits since last save (resets after each save)
|
||||
const aiBuilderMadeEdits = ref(false);
|
||||
|
||||
@@ -361,6 +379,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
loadedSessionsForWorkflowId.value = undefined;
|
||||
hasHadSuccessfulExecution.value = false;
|
||||
builderMode.value = 'build';
|
||||
resetWizardState();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,6 +533,11 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
const { revertVersion } = currentStreamingMessage.value ?? {};
|
||||
currentStreamingMessage.value = undefined;
|
||||
|
||||
// Reset wizard state when streaming ends with a workflow update (AI changed the workflow)
|
||||
if (userMessageId && hasWorkflowUpdateInCurrentBatch(userMessageId)) {
|
||||
resetWizardState();
|
||||
}
|
||||
|
||||
// Only show "Restore version" on user messages that triggered a workflow modification.
|
||||
// During planning or question phases no workflow changes happen, so skip it.
|
||||
if (userMessageId && revertVersion && hasWorkflowUpdateInCurrentBatch(userMessageId)) {
|
||||
@@ -1325,6 +1349,9 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
hasTodosHiddenByPinnedData,
|
||||
hasHadSuccessfulExecution,
|
||||
lastUserMessageId,
|
||||
wizardCurrentStep,
|
||||
wizardClearedPlaceholders,
|
||||
wizardHasExecutedWorkflow,
|
||||
|
||||
// Methods
|
||||
unpinAllNodes,
|
||||
|
||||
+3
-3
@@ -1680,7 +1680,7 @@ describe('AskAssistantBuild', () => {
|
||||
expect(queryByTestId('notification-permission-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should keep notification banner visible after streaming ends', async () => {
|
||||
it('should hide notification banner after streaming ends', async () => {
|
||||
mockCanPrompt.value = true;
|
||||
|
||||
const { queryByTestId } = renderComponent();
|
||||
@@ -1691,11 +1691,11 @@ describe('AskAssistantBuild', () => {
|
||||
|
||||
expect(queryByTestId('notification-permission-banner')).toBeInTheDocument();
|
||||
|
||||
// End streaming - banner should remain visible
|
||||
// End streaming - banner should disappear
|
||||
builderStore.$patch({ streaming: false });
|
||||
await flushPromises();
|
||||
|
||||
expect(queryByTestId('notification-permission-banner')).toBeInTheDocument();
|
||||
expect(queryByTestId('notification-permission-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show notification banner for existing chat sessions without streaming', async () => {
|
||||
|
||||
+13
-13
@@ -104,18 +104,8 @@ const chatInputRef = ref<InstanceType<typeof ChatInputWithMention>>();
|
||||
const suggestionsInputRef = ref<InstanceType<typeof ChatInputWithMention>>();
|
||||
const inputText = ref('');
|
||||
|
||||
const notificationsPermissionsBannerTriggered = ref(false);
|
||||
const creditBannerDismissed = ref(false);
|
||||
|
||||
watch(
|
||||
() => builderStore.streaming,
|
||||
(isStreaming) => {
|
||||
if (isStreaming && canPrompt.value) {
|
||||
notificationsPermissionsBannerTriggered.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => builderStore.creditsRemaining,
|
||||
() => {
|
||||
@@ -133,7 +123,7 @@ const showUsabilityNotice = computed(
|
||||
);
|
||||
|
||||
const shouldShowNotificationBanner = computed(() => {
|
||||
return notificationsPermissionsBannerTriggered.value && canPrompt.value;
|
||||
return builderStore.streaming && canPrompt.value;
|
||||
});
|
||||
|
||||
watch(shouldShowNotificationBanner, (isShown) => {
|
||||
@@ -183,7 +173,7 @@ const showExecuteMessage = computed(() => {
|
||||
return (
|
||||
!builderStore.streaming &&
|
||||
(workflowDocumentStore.value?.allNodes ?? []).length > 0 &&
|
||||
builderUpdatedWorkflowMessageIndex > -1 &&
|
||||
builderStore.hasMessages &&
|
||||
!hasErrorAfterUpdate &&
|
||||
!hasTaskAbortedAfterUpdate &&
|
||||
!hasPendingInteraction
|
||||
@@ -345,7 +335,6 @@ function onNewWorkflow() {
|
||||
builderStore.resetBuilderChat();
|
||||
processedWorkflowUpdates.value.clear();
|
||||
accumulatedNodeIdsToTidyUp.value = [];
|
||||
notificationsPermissionsBannerTriggered.value = false;
|
||||
}
|
||||
|
||||
function onFeedback(feedback: RatingFeedback) {
|
||||
@@ -367,6 +356,17 @@ function onFeedback(feedback: RatingFeedback) {
|
||||
}
|
||||
|
||||
async function onWorkflowExecuted() {
|
||||
// The wizard executes individual nodes, not the full workflow,
|
||||
// so there's no full execution data to inspect — just send success.
|
||||
if (builderStore.wizardHasExecutedWorkflow) {
|
||||
await builderStore.sendChatMessage({
|
||||
text: i18n.baseText('aiAssistant.builder.executeMessage.wizardSetupSuccess'),
|
||||
type: 'execution',
|
||||
executionStatus: 'success',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const executionData = workflowsStore.workflowExecutionData;
|
||||
const executionStatus = executionData?.status ?? 'unknown';
|
||||
const errorNodeName = executionData?.data?.resultData.lastNodeExecuted;
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestNode } from '@/__tests__/mocks';
|
||||
import type { NodeSetupState } from '@/features/setupPanel/setupPanel.types';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import BuilderSetupCard from './BuilderSetupCard.vue';
|
||||
|
||||
vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRoute: () => ({ params: {} }),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/components/NodeIcon.vue', () => ({
|
||||
default: {
|
||||
template: '<span data-test-id="node-icon" />',
|
||||
props: ['nodeType', 'size'],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/credentials/components/CredentialIcon.vue', () => ({
|
||||
default: {
|
||||
template: '<span data-test-id="credential-icon" />',
|
||||
props: ['credentialTypeName', 'size'],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/credentials/components/NodeCredentials.vue', () => ({
|
||||
default: {
|
||||
template: '<div data-test-id="node-credentials" />',
|
||||
props: ['node', 'overrideCredType', 'skipAutoSelect', 'hideIssues'],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/ndv/parameters/components/ParameterInputList.vue', () => ({
|
||||
default: {
|
||||
template: '<div data-test-id="parameter-input-list" />',
|
||||
props: ['parameters', 'nodeValues', 'node'],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/setupPanel/components/TriggerExecuteButton.vue', () => ({
|
||||
default: {
|
||||
template: '<button data-test-id="trigger-execute-button">Test step</button>',
|
||||
props: ['label', 'icon', 'disabled', 'loading', 'tooltipItems'],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/setupPanel/components/WebhookUrlPreview.vue', () => ({
|
||||
default: {
|
||||
template: '<div data-test-id="webhook-url-preview" />',
|
||||
props: ['urls'],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/setupPanel/composables/useTriggerExecution', () => ({
|
||||
useTriggerExecution: () => ({
|
||||
isExecuting: { value: false },
|
||||
isButtonDisabled: { value: false },
|
||||
label: { value: 'Test step' },
|
||||
buttonIcon: { value: 'play' },
|
||||
tooltipItems: { value: [] },
|
||||
execute: vi.fn(),
|
||||
isInListeningState: { value: false },
|
||||
listeningHint: { value: '' },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/setupPanel/composables/useWebhookUrls', () => ({
|
||||
useWebhookUrls: () => ({
|
||||
webhookUrls: { value: [] },
|
||||
}),
|
||||
}));
|
||||
|
||||
const { mockIsNodeExecutable } = vi.hoisted(() => ({
|
||||
mockIsNodeExecutable: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useNodeHelpers', () => ({
|
||||
useNodeHelpers: () => ({
|
||||
isNodeExecutable: mockIsNodeExecutable,
|
||||
updateNodesParameterIssues: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/workflows/canvas/experimental/composables/useExpressionResolveCtx', () => ({
|
||||
useExpressionResolveCtx: () => ({ value: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: () => ({
|
||||
isCredentialTestPending: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
}));
|
||||
|
||||
function createNode(overrides: Partial<INodeUi> = {}): INodeUi {
|
||||
return createTestNode({
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
...overrides,
|
||||
}) as INodeUi;
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<NodeSetupState> = {}): NodeSetupState {
|
||||
return {
|
||||
node: createNode(),
|
||||
parameterIssues: {},
|
||||
isTrigger: false,
|
||||
isComplete: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const renderComponent = createComponentRenderer(BuilderSetupCard);
|
||||
|
||||
describe('BuilderSetupCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsNodeExecutable.mockReturnValue(false);
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
function render(
|
||||
stateOverrides: Partial<NodeSetupState> = {},
|
||||
props: Record<string, unknown> = {},
|
||||
) {
|
||||
return renderComponent({
|
||||
props: {
|
||||
state: createState(stateOverrides),
|
||||
stepIndex: 0,
|
||||
totalCards: 1,
|
||||
firstTriggerName: null,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('renders header with node name', () => {
|
||||
const { getByText } = render({ node: createNode({ name: 'HTTP Request' }) });
|
||||
expect(getByText('HTTP Request')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows check icon when complete', () => {
|
||||
const { getByTestId } = render({ isComplete: true });
|
||||
expect(getByTestId('builder-setup-card-check')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show check icon when incomplete', () => {
|
||||
const { queryByTestId } = render({ isComplete: false });
|
||||
expect(queryByTestId('builder-setup-card-check')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies completed style when isComplete is true', () => {
|
||||
const { getByTestId } = render({ isComplete: true });
|
||||
const card = getByTestId('builder-setup-card');
|
||||
expect(card.className).toMatch(/completed/);
|
||||
});
|
||||
|
||||
describe('navigation arrows', () => {
|
||||
it('hides arrows when single card', () => {
|
||||
const { queryByTestId } = render({ isComplete: true }, { totalCards: 1 });
|
||||
expect(queryByTestId('builder-setup-card-prev')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('builder-setup-card-next')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows arrows when multiple cards', () => {
|
||||
const { getByTestId } = render({}, { totalCards: 3, stepIndex: 1 });
|
||||
expect(getByTestId('builder-setup-card-prev')).toBeInTheDocument();
|
||||
expect(getByTestId('builder-setup-card-next')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables prev arrow at first step but shows next arrow enabled', () => {
|
||||
const { getByTestId } = render({}, { totalCards: 3, stepIndex: 0 });
|
||||
expect(getByTestId('builder-setup-card-prev')).toBeDisabled();
|
||||
expect(getByTestId('builder-setup-card-next')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables next arrow at last step but shows prev arrow enabled', () => {
|
||||
const { getByTestId } = render({}, { totalCards: 3, stepIndex: 2 });
|
||||
expect(getByTestId('builder-setup-card-next')).toBeDisabled();
|
||||
expect(getByTestId('builder-setup-card-prev')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('continue button', () => {
|
||||
it('shows continue as primary on non-executable, multi-card, non-last', () => {
|
||||
const { getByTestId } = render({ isComplete: false }, { totalCards: 3, stepIndex: 0 });
|
||||
const continueButton = getByTestId('builder-setup-card-continue');
|
||||
expect(continueButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables continue when credentials required but not selected', () => {
|
||||
const { getByTestId } = render(
|
||||
{ isComplete: false, credentialType: 'openAiApi', showCredentialPicker: true },
|
||||
{ totalCards: 3, stepIndex: 0 },
|
||||
);
|
||||
const continueButton = getByTestId('builder-setup-card-continue') as HTMLButtonElement;
|
||||
expect(continueButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('enables continue when credentials are selected', () => {
|
||||
const { getByTestId } = render(
|
||||
{
|
||||
isComplete: false,
|
||||
credentialType: 'openAiApi',
|
||||
selectedCredentialId: 'cred-1',
|
||||
showCredentialPicker: true,
|
||||
},
|
||||
{ totalCards: 3, stepIndex: 0 },
|
||||
);
|
||||
const continueButton = getByTestId('builder-setup-card-continue') as HTMLButtonElement;
|
||||
expect(continueButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('hides continue on last card', () => {
|
||||
const { queryByTestId } = render({ isComplete: false }, { totalCards: 3, stepIndex: 2 });
|
||||
expect(queryByTestId('builder-setup-card-continue')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('step indicator', () => {
|
||||
it('shows step indicator in footer', () => {
|
||||
const { getByText } = render({}, { totalCards: 4, stepIndex: 1 });
|
||||
expect(getByText('2 of 4')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute button', () => {
|
||||
it('does not show execute button for tool nodes', () => {
|
||||
mockIsNodeExecutable.mockReturnValue(true);
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
// @ts-expect-error -- pinia test store allows overriding computed
|
||||
nodeTypesStore.isToolNode = () => true;
|
||||
|
||||
const { queryByTestId } = render(
|
||||
{ node: createNode({ type: '@n8n/n8n-nodes-langchain.chatTool' }) },
|
||||
{ totalCards: 2, stepIndex: 0 },
|
||||
);
|
||||
expect(queryByTestId('trigger-execute-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('credential section', () => {
|
||||
it('shows credential section when showCredentialPicker is true', () => {
|
||||
const { getByTestId } = render({
|
||||
showCredentialPicker: true,
|
||||
credentialType: 'openAiApi',
|
||||
credentialDisplayName: 'OpenAI',
|
||||
});
|
||||
expect(getByTestId('node-credentials')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides credential section when showCredentialPicker is false', () => {
|
||||
const { queryByTestId } = render({
|
||||
showCredentialPicker: false,
|
||||
});
|
||||
expect(queryByTestId('node-credentials')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide, watch } from 'vue';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { N8nButton, N8nCallout, N8nIcon, N8nLink, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
|
||||
import NodeIcon from '@/app/components/NodeIcon.vue';
|
||||
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
|
||||
import NodeCredentials from '@/features/credentials/components/NodeCredentials.vue';
|
||||
import ParameterInputList from '@/features/ndv/parameters/components/ParameterInputList.vue';
|
||||
import TriggerExecuteButton from '@/features/setupPanel/components/TriggerExecuteButton.vue';
|
||||
import WebhookUrlPreview from '@/features/setupPanel/components/WebhookUrlPreview.vue';
|
||||
|
||||
import type { NodeSetupState } from '@/features/setupPanel/setupPanel.types';
|
||||
import type { INodeUi, INodeUpdatePropertiesInformation, IUpdateInformation } from '@/Interface';
|
||||
import { type INodeProperties, NodeHelpers } from 'n8n-workflow';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
|
||||
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
|
||||
import { ExpressionLocalResolveContextSymbol } from '@/app/constants';
|
||||
import { isHttpRequestNodeType } from '@/features/setupPanel/setupPanel.utils';
|
||||
import { useExpressionResolveCtx } from '@/features/workflows/canvas/experimental/composables/useExpressionResolveCtx';
|
||||
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
|
||||
import { useTriggerExecution } from '@/features/setupPanel/composables/useTriggerExecution';
|
||||
import { useWebhookUrls } from '@/features/setupPanel/composables/useWebhookUrls';
|
||||
|
||||
const NESTED_PARAM_TYPES = new Set([
|
||||
'collection',
|
||||
'fixedCollection',
|
||||
'resourceMapper',
|
||||
'filter',
|
||||
'assignmentCollection',
|
||||
]);
|
||||
|
||||
const props = defineProps<{
|
||||
state: NodeSetupState;
|
||||
stepIndex: number;
|
||||
totalCards: number;
|
||||
firstTriggerName: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
goToNext: [];
|
||||
goToPrev: [];
|
||||
continueCurrent: [];
|
||||
stepExecuted: [];
|
||||
credentialSelected: [payload: { credentialType: string; credentialId: string; nodeName: string }];
|
||||
credentialDeselected: [payload: { credentialType: string; nodeName: string }];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const workflowState = injectWorkflowState();
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
// Expression context for ParameterInputList
|
||||
const node = computed<INodeUi | null>(() => props.state.node);
|
||||
const expressionResolveCtx = useExpressionResolveCtx(node);
|
||||
provide(ExpressionLocalResolveContextSymbol, expressionResolveCtx);
|
||||
|
||||
const nodeType = computed(() =>
|
||||
nodeTypesStore.getNodeType(props.state.node.type, props.state.node.typeVersion),
|
||||
);
|
||||
|
||||
const isHttpRequestNode = computed(() => isHttpRequestNodeType(props.state.node.type));
|
||||
|
||||
const hasCredential = computed(() => !!props.state.credentialType);
|
||||
|
||||
const executableNode = computed<INodeUi | null>(() => {
|
||||
if (nodeTypesStore.isToolNode(props.state.node.type)) return null;
|
||||
if (props.state.isTrigger) {
|
||||
if (!props.firstTriggerName) return null;
|
||||
return props.state.node.name === props.firstTriggerName ? props.state.node : null;
|
||||
}
|
||||
if (!nodeHelpers.isNodeExecutable(props.state.node, true, [])) return null;
|
||||
return props.state.node;
|
||||
});
|
||||
|
||||
const {
|
||||
isExecuting,
|
||||
isButtonDisabled,
|
||||
label: executeLabel,
|
||||
buttonIcon: executeButtonIcon,
|
||||
tooltipItems: executeTooltipItems,
|
||||
execute,
|
||||
isInListeningState,
|
||||
listeningHint,
|
||||
} = useTriggerExecution(executableNode);
|
||||
|
||||
const { webhookUrls } = useWebhookUrls(executableNode);
|
||||
|
||||
const isActive = computed(() => isExecuting.value || isInListeningState.value);
|
||||
|
||||
const isTestingCredential = computed(() => {
|
||||
const id = props.state.selectedCredentialId;
|
||||
return !!id && credentialsStore.isCredentialTestPending(id);
|
||||
});
|
||||
|
||||
const allParameters = computed<INodeProperties[]>(() => {
|
||||
if (!nodeType.value?.properties) return [];
|
||||
|
||||
const issueParamNames = Object.keys(props.state.parameterIssues);
|
||||
const additionalParamNames = props.state.additionalParameterNames ?? [];
|
||||
const allParamNames = new Set([...issueParamNames, ...additionalParamNames]);
|
||||
|
||||
return nodeType.value.properties.filter(
|
||||
(prop) =>
|
||||
allParamNames.has(prop.name) &&
|
||||
NodeHelpers.displayParameter(
|
||||
props.state.node.parameters,
|
||||
prop,
|
||||
props.state.node,
|
||||
nodeType.value,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const isNestedParam = (p: INodeProperties) =>
|
||||
NESTED_PARAM_TYPES.has(p.type) || p.typeOptions?.multipleValues === true;
|
||||
|
||||
const simpleParameters = computed(() => allParameters.value.filter((p) => !isNestedParam(p)));
|
||||
const nestedParameterCount = computed(() => allParameters.value.filter(isNestedParam).length);
|
||||
|
||||
const hasShownParameters = computed(() => allParameters.value.length > 0);
|
||||
|
||||
const openNdv = () => {
|
||||
ndvStore.setActiveNodeName(props.state.node.name, 'other');
|
||||
};
|
||||
|
||||
const isTriggerOnly = computed(
|
||||
() => props.state.isTrigger && !hasCredential.value && !hasShownParameters.value,
|
||||
);
|
||||
|
||||
const useCredentialIcon = computed(
|
||||
() => hasCredential.value && !hasShownParameters.value && !isTriggerOnly.value,
|
||||
);
|
||||
|
||||
const nodeNames = computed(() => (props.state.allNodesUsingCredential ?? []).map((n) => n.name));
|
||||
const nodeNamesTooltip = computed(() => nodeNames.value.join(', '));
|
||||
|
||||
const isComplete = computed(() => props.state.isComplete);
|
||||
const isExecutable = computed(() => executableNode.value !== null);
|
||||
const isLastCard = computed(() => props.stepIndex === props.totalCards - 1);
|
||||
|
||||
const showContinue = computed(
|
||||
() => !isExecutable.value && props.totalCards > 1 && !isLastCard.value,
|
||||
);
|
||||
|
||||
const isContinueDisabled = computed(() => hasCredential.value && !props.state.selectedCredentialId);
|
||||
|
||||
const showArrows = computed(() => props.totalCards > 1);
|
||||
const isPrevDisabled = computed(() => props.stepIndex === 0);
|
||||
const isNextDisabled = computed(() => isLastCard.value);
|
||||
|
||||
const showTriggerCallout = computed(() => props.state.isTrigger && isInListeningState.value);
|
||||
|
||||
const onCredentialSelected = (updateInfo: INodeUpdatePropertiesInformation) => {
|
||||
if (!props.state.credentialType) return;
|
||||
|
||||
const credentialData = updateInfo.properties.credentials?.[props.state.credentialType];
|
||||
const credentialId = credentialData?.id;
|
||||
|
||||
if (credentialId) {
|
||||
emit('credentialSelected', {
|
||||
credentialType: props.state.credentialType,
|
||||
credentialId,
|
||||
nodeName: props.state.node.name,
|
||||
});
|
||||
} else {
|
||||
emit('credentialDeselected', {
|
||||
credentialType: props.state.credentialType,
|
||||
nodeName: props.state.node.name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onValueChanged = (parameterData: IUpdateInformation) => {
|
||||
const paramName = parameterData.name.replace(/^parameters\./, '');
|
||||
workflowState.updateNodeProperties({
|
||||
name: props.state.node.name,
|
||||
properties: {
|
||||
parameters: {
|
||||
...props.state.node.parameters,
|
||||
[paramName]: parameterData.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
nodeHelpers.updateNodesParameterIssues();
|
||||
};
|
||||
|
||||
// Notify parent when step execution finishes (for auto-advance / wizard dismissal).
|
||||
// Emit when the node ran successfully OR was not reached (e.g. on an inactive branch).
|
||||
// Only skip when the node actually errored.
|
||||
// Uses isActive (executing OR listening) because for non-schedule/non-manual triggers
|
||||
// isExecuting stays false throughout the listening lifecycle.
|
||||
watch(isActive, (active, wasActive) => {
|
||||
if (wasActive && !active) {
|
||||
const runData = workflowsStore.getWorkflowResultDataByNodeName(props.state.node.name);
|
||||
const lastRun = runData?.[runData.length - 1];
|
||||
if (!lastRun?.error) {
|
||||
emit('stepExecuted');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-test-id="builder-setup-card" :class="[$style.card, { [$style.completed]: isComplete }]">
|
||||
<!-- Header -->
|
||||
<header :class="$style.header">
|
||||
<CredentialIcon
|
||||
v-if="useCredentialIcon"
|
||||
:credential-type-name="state.credentialType!"
|
||||
:size="16"
|
||||
/>
|
||||
<NodeIcon v-else :node-type="nodeType" :size="16" />
|
||||
<N8nText :class="$style.title" size="medium" color="text-dark" bold>
|
||||
{{ state.node.name }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-if="isComplete"
|
||||
data-test-id="builder-setup-card-check"
|
||||
:class="$style.completeLabel"
|
||||
size="medium"
|
||||
color="success"
|
||||
>
|
||||
<N8nIcon icon="check" size="large" />
|
||||
{{ i18n.baseText('generic.complete') }}
|
||||
</N8nText>
|
||||
</header>
|
||||
|
||||
<!-- Trigger listening callout -->
|
||||
<N8nCallout
|
||||
v-if="showTriggerCallout"
|
||||
:class="$style.callout"
|
||||
data-test-id="trigger-listening-callout"
|
||||
theme="secondary"
|
||||
>
|
||||
{{ listeningHint }}
|
||||
</N8nCallout>
|
||||
|
||||
<!-- Webhook URL preview -->
|
||||
<WebhookUrlPreview
|
||||
v-if="state.isTrigger && isInListeningState && webhookUrls.length > 0"
|
||||
:class="$style.callout"
|
||||
:urls="webhookUrls"
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-if="!isTriggerOnly" :class="$style.content">
|
||||
<div v-if="state.showCredentialPicker" :class="$style.credentialContainer">
|
||||
<NodeCredentials
|
||||
:node="state.node"
|
||||
:override-cred-type="state.credentialType ?? ''"
|
||||
:skip-auto-select="isHttpRequestNode"
|
||||
hide-issues
|
||||
@credential-selected="onCredentialSelected"
|
||||
>
|
||||
<template v-if="nodeNames.length > 1" #label-postfix>
|
||||
<N8nTooltip placement="top">
|
||||
<template #content>
|
||||
{{ nodeNamesTooltip }}
|
||||
</template>
|
||||
<N8nText data-test-id="builder-setup-card-nodes-hint" size="small" color="text-light">
|
||||
{{
|
||||
i18n.baseText('setupPanel.usedInNodes' as BaseTextKey, {
|
||||
interpolate: { count: String(nodeNames.length) },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
</NodeCredentials>
|
||||
</div>
|
||||
|
||||
<ParameterInputList
|
||||
v-if="simpleParameters.length > 0"
|
||||
:parameters="simpleParameters"
|
||||
:node-values="{ parameters: state.node.parameters }"
|
||||
:remove-first-parameter-margin="true"
|
||||
:node="state.node"
|
||||
:hide-delete="true"
|
||||
path="parameters"
|
||||
:options-overrides="{ hideExpressionSelector: true, hideFocusPanelButton: true }"
|
||||
@value-changed="onValueChanged"
|
||||
/>
|
||||
|
||||
<N8nLink
|
||||
v-if="nestedParameterCount > 0"
|
||||
data-test-id="builder-setup-card-configure-link"
|
||||
:underline="true"
|
||||
theme="text"
|
||||
size="medium"
|
||||
@click="openNdv"
|
||||
>
|
||||
{{
|
||||
i18n.baseText('aiAssistant.builder.setupWizard.configureParameters' as BaseTextKey, {
|
||||
adjustToNumber: nestedParameterCount,
|
||||
interpolate: { count: String(nestedParameterCount) },
|
||||
})
|
||||
}}
|
||||
</N8nLink>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer :class="$style.footer">
|
||||
<div :class="$style.footerNav">
|
||||
<N8nButton
|
||||
v-if="showArrows"
|
||||
variant="ghost"
|
||||
size="xsmall"
|
||||
icon-only
|
||||
:disabled="isPrevDisabled"
|
||||
data-test-id="builder-setup-card-prev"
|
||||
aria-label="Previous step"
|
||||
@click="emit('goToPrev')"
|
||||
>
|
||||
<N8nIcon icon="chevron-left" size="xsmall" />
|
||||
</N8nButton>
|
||||
<N8nText size="small" color="text-light"> {{ stepIndex + 1 }} of {{ totalCards }} </N8nText>
|
||||
<N8nButton
|
||||
v-if="showArrows"
|
||||
variant="ghost"
|
||||
size="xsmall"
|
||||
icon-only
|
||||
:disabled="isNextDisabled"
|
||||
data-test-id="builder-setup-card-next"
|
||||
aria-label="Next step"
|
||||
@click="emit('goToNext')"
|
||||
>
|
||||
<N8nIcon icon="chevron-right" size="xsmall" />
|
||||
</N8nButton>
|
||||
</div>
|
||||
|
||||
<div :class="$style.footerActions">
|
||||
<N8nButton
|
||||
v-if="showContinue"
|
||||
data-test-id="builder-setup-card-continue"
|
||||
type="primary"
|
||||
size="small"
|
||||
:class="$style.actionButton"
|
||||
:disabled="isContinueDisabled"
|
||||
:label="i18n.baseText('aiAssistant.builder.setupWizard.continue' as BaseTextKey)"
|
||||
@click="emit('continueCurrent')"
|
||||
/>
|
||||
|
||||
<TriggerExecuteButton
|
||||
v-if="isExecutable"
|
||||
:label="executeLabel"
|
||||
:icon="executeButtonIcon"
|
||||
:disabled="isButtonDisabled || isTestingCredential"
|
||||
:loading="isExecuting"
|
||||
:tooltip-items="executeTooltipItems"
|
||||
@click="execute"
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.card {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
padding: 0;
|
||||
background-color: var(--color--background--light-3);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius);
|
||||
|
||||
&.completed {
|
||||
border-color: var(--color--success);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--sm) var(--spacing--sm) 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.completeLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.callout {
|
||||
margin: 0 var(--spacing--sm);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
padding: 0 var(--spacing--sm);
|
||||
}
|
||||
|
||||
.credentialContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:global(.node-credentials) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
border-top: var(--border);
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
}
|
||||
|
||||
.footerNav {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
--button--font-size: var(--font-size--2xs);
|
||||
}
|
||||
</style>
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestNode } from '@/__tests__/mocks';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import BuilderSetupWizard from './BuilderSetupWizard.vue';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useBuilderStore } from '../../builder.store';
|
||||
|
||||
const mockCards = ref<Array<{ state: Record<string, unknown> }>>([]);
|
||||
const mockCurrentCard = ref<{ state: Record<string, unknown> } | undefined>(undefined);
|
||||
const mockIsAllComplete = ref(false);
|
||||
const mockTotalCards = ref(0);
|
||||
const mockCurrentStepIndex = ref(0);
|
||||
|
||||
const mockOnStepExecuted = vi.fn();
|
||||
const mockIsInitialCredentialTestingDone = ref(true);
|
||||
|
||||
vi.mock('@/features/ai/assistant/composables/useBuilderSetupCards', () => ({
|
||||
useBuilderSetupCards: () => ({
|
||||
cards: mockCards,
|
||||
currentStepIndex: mockCurrentStepIndex,
|
||||
currentCard: mockCurrentCard,
|
||||
isAllComplete: mockIsAllComplete,
|
||||
isInitialCredentialTestingDone: mockIsInitialCredentialTestingDone,
|
||||
totalCards: mockTotalCards,
|
||||
firstTriggerName: ref(null),
|
||||
setCredential: vi.fn(),
|
||||
unsetCredential: vi.fn(),
|
||||
goToNext: vi.fn(),
|
||||
goToPrev: vi.fn(),
|
||||
goToStep: vi.fn(),
|
||||
continueCurrent: vi.fn(),
|
||||
onStepExecuted: mockOnStepExecuted,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./BuilderSetupCard.vue', () => ({
|
||||
default: {
|
||||
template: '<div data-test-id="builder-setup-card" @click="$emit(\'stepExecuted\')" />',
|
||||
props: ['state', 'stepIndex', 'totalCards', 'firstTriggerName'],
|
||||
emits: [
|
||||
'stepExecuted',
|
||||
'goToNext',
|
||||
'goToPrev',
|
||||
'continueCurrent',
|
||||
'credentialSelected',
|
||||
'credentialDeselected',
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRoute: () => ({ params: {} }),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
const triggerNode = createTestNode({
|
||||
id: '1',
|
||||
name: 'Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
}) as INodeUi;
|
||||
|
||||
const renderComponent = createComponentRenderer(BuilderSetupWizard);
|
||||
|
||||
describe('BuilderSetupWizard', () => {
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
let builderStore: ReturnType<typeof mockedStore<typeof useBuilderStore>>;
|
||||
let pinia: ReturnType<typeof createTestingPinia>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCards.value = [];
|
||||
mockCurrentCard.value = undefined;
|
||||
mockIsAllComplete.value = false;
|
||||
mockTotalCards.value = 0;
|
||||
mockCurrentStepIndex.value = 0;
|
||||
mockIsInitialCredentialTestingDone.value = true;
|
||||
|
||||
pinia = createTestingPinia({ stubActions: false });
|
||||
setActivePinia(pinia);
|
||||
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
builderStore = mockedStore(useBuilderStore);
|
||||
|
||||
workflowsStore.workflow.nodes = [triggerNode];
|
||||
workflowsStore.workflow.connections = {} as never;
|
||||
Object.defineProperty(builderStore, 'hasTodosHiddenByPinnedData', { get: () => false });
|
||||
Object.defineProperty(builderStore, 'wizardHasExecutedWorkflow', {
|
||||
value: false,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
builderStore.trackWorkflowBuilderJourney = vi.fn();
|
||||
});
|
||||
|
||||
function render() {
|
||||
return renderComponent({ pinia });
|
||||
}
|
||||
|
||||
function setCurrentCard(overrides: { isComplete?: boolean } = {}) {
|
||||
mockCurrentCard.value = {
|
||||
state: {
|
||||
node: triggerNode,
|
||||
parameterIssues: {},
|
||||
isTrigger: true,
|
||||
isComplete: overrides.isComplete ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('renders wizard container', () => {
|
||||
const { getByTestId } = render();
|
||||
expect(getByTestId('builder-setup-wizard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows description text', () => {
|
||||
const { getByText } = render();
|
||||
expect(getByText('aiAssistant.builder.executeMessage.description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no-issues text when all complete', () => {
|
||||
mockIsAllComplete.value = true;
|
||||
|
||||
const { getByText } = render();
|
||||
expect(getByText('aiAssistant.builder.executeMessage.noIssues')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows setup card when currentCard exists', () => {
|
||||
setCurrentCard();
|
||||
mockTotalCards.value = 1;
|
||||
|
||||
const { getByTestId } = render();
|
||||
expect(getByTestId('builder-setup-card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show setup card when no current card', () => {
|
||||
mockCurrentCard.value = undefined;
|
||||
|
||||
const { queryByTestId } = render();
|
||||
expect(queryByTestId('builder-setup-card')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tracks setup_wizard_shown when card becomes visible', () => {
|
||||
setCurrentCard();
|
||||
mockTotalCards.value = 1;
|
||||
|
||||
render();
|
||||
expect(builderStore.trackWorkflowBuilderJourney).toHaveBeenCalledWith(
|
||||
'setup_wizard_shown',
|
||||
expect.objectContaining({ total: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows card when all complete but workflow not executed yet', () => {
|
||||
setCurrentCard({ isComplete: true });
|
||||
mockTotalCards.value = 1;
|
||||
mockIsAllComplete.value = true;
|
||||
builderStore.wizardHasExecutedWorkflow = false;
|
||||
|
||||
const { getByTestId } = render();
|
||||
expect(getByTestId('builder-setup-card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides card when all complete and workflow has been executed', () => {
|
||||
setCurrentCard({ isComplete: true });
|
||||
mockTotalCards.value = 1;
|
||||
mockIsAllComplete.value = true;
|
||||
builderStore.wizardHasExecutedWorkflow = true;
|
||||
|
||||
const { queryByTestId } = render();
|
||||
expect(queryByTestId('builder-setup-card')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dismisses wizard when last step is executed successfully', async () => {
|
||||
setCurrentCard({ isComplete: true });
|
||||
mockTotalCards.value = 1;
|
||||
mockCurrentStepIndex.value = 0;
|
||||
mockIsAllComplete.value = true;
|
||||
|
||||
// The composable's onStepExecuted sets this flag for the last card
|
||||
mockOnStepExecuted.mockImplementation(() => {
|
||||
builderStore.wizardHasExecutedWorkflow = true;
|
||||
});
|
||||
|
||||
const { getByTestId, queryByTestId } = render();
|
||||
expect(getByTestId('builder-setup-wizard')).toBeInTheDocument();
|
||||
|
||||
// Click the mock card to trigger stepExecuted
|
||||
await fireEvent.click(getByTestId('builder-setup-card'));
|
||||
|
||||
expect(queryByTestId('builder-setup-wizard')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits noSetupNeeded when there are no cards and builder has finished updating', () => {
|
||||
mockTotalCards.value = 0;
|
||||
builderStore.setBuilderMadeEdits(true);
|
||||
|
||||
const { emitted } = render();
|
||||
expect(emitted().noSetupNeeded).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not emit noSetupNeeded when there are no cards but builder is still updating', () => {
|
||||
mockTotalCards.value = 0;
|
||||
|
||||
const { emitted } = render();
|
||||
expect(emitted().noSetupNeeded).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits noSetupNeeded when all cards are already complete on load', () => {
|
||||
setCurrentCard({ isComplete: true });
|
||||
mockTotalCards.value = 1;
|
||||
mockIsAllComplete.value = true;
|
||||
builderStore.wizardHasExecutedWorkflow = true;
|
||||
|
||||
const { emitted } = render();
|
||||
expect(emitted().noSetupNeeded).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not emit noSetupNeeded when there are incomplete cards', () => {
|
||||
mockTotalCards.value = 2;
|
||||
setCurrentCard();
|
||||
|
||||
const { emitted } = render();
|
||||
expect(emitted().noSetupNeeded).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not dismiss wizard when a non-last step is executed', async () => {
|
||||
setCurrentCard({ isComplete: true });
|
||||
mockTotalCards.value = 3;
|
||||
mockCurrentStepIndex.value = 0;
|
||||
|
||||
const { getByTestId } = render();
|
||||
|
||||
// Click the mock card to trigger stepExecuted
|
||||
await fireEvent.click(getByTestId('builder-setup-card'));
|
||||
|
||||
expect(getByTestId('builder-setup-wizard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { N8nIcon, N8nText } from '@n8n/design-system';
|
||||
|
||||
import BuilderSetupCard from './BuilderSetupCard.vue';
|
||||
import { useBuilderSetupCards } from '@/features/ai/assistant/composables/useBuilderSetupCards';
|
||||
import { useBuilderStore } from '@/features/ai/assistant/builder.store';
|
||||
import { useSetupPanelStore } from '@/features/setupPanel/setupPanel.store';
|
||||
|
||||
const emit = defineEmits<{
|
||||
workflowExecuted: [];
|
||||
noSetupNeeded: [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const builderStore = useBuilderStore();
|
||||
const setupPanelStore = useSetupPanelStore();
|
||||
|
||||
const {
|
||||
currentStepIndex,
|
||||
currentCard,
|
||||
isAllComplete,
|
||||
isInitialCredentialTestingDone,
|
||||
totalCards,
|
||||
firstTriggerName,
|
||||
setCredential,
|
||||
unsetCredential,
|
||||
goToNext,
|
||||
goToPrev,
|
||||
continueCurrent,
|
||||
onStepExecuted,
|
||||
} = useBuilderSetupCards();
|
||||
|
||||
const wizardDismissed = computed(
|
||||
() => isAllComplete.value && builderStore.wizardHasExecutedWorkflow,
|
||||
);
|
||||
|
||||
const isCheckingCredentials = computed(
|
||||
() => !isInitialCredentialTestingDone.value && !wizardDismissed.value,
|
||||
);
|
||||
|
||||
const showCard = computed(
|
||||
() => currentCard.value && !wizardDismissed.value && isInitialCredentialTestingDone.value,
|
||||
);
|
||||
|
||||
const showWizard = computed(() => !wizardDismissed.value);
|
||||
|
||||
const isHovering = ref(false);
|
||||
|
||||
function onMouseEnter() {
|
||||
isHovering.value = true;
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
isHovering.value = false;
|
||||
setupPanelStore.clearHighlightedNodes();
|
||||
}
|
||||
|
||||
// Highlight all nodes associated with the active card while hovering over the wizard.
|
||||
const highlightedNodeIds = computed(() => {
|
||||
const card = currentCard.value;
|
||||
if (!card) return [];
|
||||
if (card.state.allNodesUsingCredential?.length) {
|
||||
return card.state.allNodesUsingCredential.map((n) => n.id);
|
||||
}
|
||||
return [card.state.node.id];
|
||||
});
|
||||
|
||||
watch([isHovering, highlightedNodeIds, showWizard], ([hovering, nodeIds, visible]) => {
|
||||
if (hovering && nodeIds.length > 0 && visible) {
|
||||
setupPanelStore.setHighlightedNodes(nodeIds);
|
||||
} else {
|
||||
setupPanelStore.clearHighlightedNodes();
|
||||
}
|
||||
});
|
||||
|
||||
const descriptionText = computed(() => {
|
||||
if (!isAllComplete.value) {
|
||||
return i18n.baseText('aiAssistant.builder.executeMessage.description');
|
||||
}
|
||||
return i18n.baseText('aiAssistant.builder.executeMessage.noIssues');
|
||||
});
|
||||
|
||||
function onGoToNext() {
|
||||
builderStore.trackWorkflowBuilderJourney('setup_wizard_step_navigated', {
|
||||
step: currentStepIndex.value + 2,
|
||||
total: totalCards.value,
|
||||
direction: 'next',
|
||||
});
|
||||
goToNext();
|
||||
}
|
||||
|
||||
function onGoToPrev() {
|
||||
builderStore.trackWorkflowBuilderJourney('setup_wizard_step_navigated', {
|
||||
step: currentStepIndex.value,
|
||||
total: totalCards.value,
|
||||
direction: 'prev',
|
||||
});
|
||||
goToPrev();
|
||||
}
|
||||
|
||||
function onCredentialSelected(payload: {
|
||||
credentialType: string;
|
||||
credentialId: string;
|
||||
nodeName: string;
|
||||
}) {
|
||||
setCredential(payload.credentialType, payload.credentialId, payload.nodeName);
|
||||
}
|
||||
|
||||
function onCredentialDeselected(payload: { credentialType: string; nodeName: string }) {
|
||||
unsetCredential(payload.credentialType, payload.nodeName);
|
||||
}
|
||||
|
||||
function handleStepExecuted() {
|
||||
onStepExecuted();
|
||||
if (builderStore.wizardHasExecutedWorkflow) {
|
||||
setupPanelStore.clearHighlightedNodes();
|
||||
emit('workflowExecuted');
|
||||
}
|
||||
}
|
||||
|
||||
const hasTrackedShown = ref(false);
|
||||
watch(
|
||||
showCard,
|
||||
(visible) => {
|
||||
if (visible && !hasTrackedShown.value) {
|
||||
hasTrackedShown.value = true;
|
||||
builderStore.trackWorkflowBuilderJourney('setup_wizard_shown', {
|
||||
total: totalCards.value,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
[totalCards, wizardDismissed],
|
||||
([count, dismissed]) => {
|
||||
// Guard: don't emit noSetupNeeded while the builder is still applying
|
||||
// the workflow update. Between addNewNodes() and updateConnections() there is
|
||||
// an async gap (await nextTick) where connections are not yet in the store,
|
||||
// causing sortNodesByExecutionOrder to drop non-trigger nodes and totalCards
|
||||
// to be transiently 0. Once setBuilderMadeEdits(true) fires the update is
|
||||
// complete and we can trust the card count.
|
||||
if (count === 0 && !builderStore.getAiBuilderMadeEdits()) return;
|
||||
|
||||
if ((count === 0 || dismissed) && !hasTrackedShown.value) {
|
||||
emit('noSetupNeeded');
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showWizard"
|
||||
data-test-id="builder-setup-wizard"
|
||||
:class="$style.container"
|
||||
role="region"
|
||||
aria-label="Workflow setup wizard"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<N8nText
|
||||
v-if="isCheckingCredentials"
|
||||
:class="$style.checkingCredentials"
|
||||
size="medium"
|
||||
color="text-light"
|
||||
>
|
||||
{{ i18n.baseText('aiAssistant.builder.setupWizard.checkingCredentials' as BaseTextKey) }}
|
||||
<N8nIcon icon="chevron-right" size="small" />
|
||||
</N8nText>
|
||||
<template v-else>
|
||||
<N8nText :class="$style.description" size="medium" color="text-dark">
|
||||
{{ descriptionText }}
|
||||
</N8nText>
|
||||
|
||||
<BuilderSetupCard
|
||||
v-if="showCard"
|
||||
:key="currentStepIndex"
|
||||
:state="currentCard!.state"
|
||||
:step-index="currentStepIndex"
|
||||
:total-cards="totalCards"
|
||||
:first-trigger-name="firstTriggerName"
|
||||
@go-to-next="onGoToNext"
|
||||
@go-to-prev="onGoToPrev"
|
||||
@step-executed="handleStepExecuted"
|
||||
@continue-current="continueCurrent"
|
||||
@credential-selected="onCredentialSelected"
|
||||
@credential-deselected="onCredentialDeselected"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.checkingCredentials {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
}
|
||||
</style>
|
||||
+32
-100
@@ -9,21 +9,19 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
|
||||
import { useInjectWorkflowId } from '@/app/composables/useInjectWorkflowId';
|
||||
import { useRunWorkflow } from '@/app/composables/useRunWorkflow';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type WatchStopHandle } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import NodeIssueItem from './NodeIssueItem.vue';
|
||||
import CredentialsSetupCard from './CredentialsSetupCard.vue';
|
||||
import BuilderSetupWizard from './BuilderSetupWizard.vue';
|
||||
import CanvasRunWorkflowButton from '@/features/workflows/canvas/components/elements/buttons/CanvasRunWorkflowButton.vue';
|
||||
import { useLogsStore } from '@/app/stores/logs.store';
|
||||
import { isChatNode } from '@/app/utils/aiUtils';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { N8nTooltip, N8nIcon, N8nButton } from '@n8n/design-system';
|
||||
import { nextTick } from 'vue';
|
||||
import { useBuilderStore } from '@/features/ai/assistant/builder.store';
|
||||
import { useBuilderExecution } from '@/features/ai/assistant/composables/useBuilderExecution';
|
||||
import { SETUP_CREDENTIALS_MODAL_KEY } from '@/app/constants';
|
||||
import { AI_BUILDER_SETUP_WIZARD_EXPERIMENT } from '@/app/constants/experiments';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import type { WorkflowValidationIssue } from '@/Interface';
|
||||
|
||||
interface Emits {
|
||||
@@ -33,8 +31,6 @@ interface Emits {
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// Initialize composables and stores
|
||||
const router = useRouter();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const workflowId = useInjectWorkflowId();
|
||||
const workflowDocumentStore = computed(() =>
|
||||
@@ -45,54 +41,31 @@ const workflowDocumentStore = computed(() =>
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const uiStore = useUIStore();
|
||||
const i18n = useI18n();
|
||||
const logsStore = useLogsStore();
|
||||
const toast = useToast();
|
||||
const builderStore = useBuilderStore();
|
||||
const posthogStore = usePostHog();
|
||||
|
||||
// Workflow execution composable
|
||||
const { runWorkflow } = useRunWorkflow({ router });
|
||||
const wizardFallback = ref(false);
|
||||
|
||||
let executionWatcherStop: WatchStopHandle | undefined;
|
||||
|
||||
const containerRef = ref<HTMLElement>();
|
||||
|
||||
const stopExecutionWatcher = () => {
|
||||
if (executionWatcherStop) {
|
||||
executionWatcherStop();
|
||||
executionWatcherStop = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up a watcher that fires exactly once per execution cycle.
|
||||
*/
|
||||
const ensureExecutionWatcher = () => {
|
||||
if (executionWatcherStop) return;
|
||||
|
||||
const RUNNING_STATES = ['running', 'waiting'];
|
||||
|
||||
executionWatcherStop = watch(
|
||||
() => workflowsStore.workflowExecutionData?.status,
|
||||
async (status) => {
|
||||
await nextTick();
|
||||
|
||||
if (!status || RUNNING_STATES.includes(status)) return;
|
||||
|
||||
stopExecutionWatcher();
|
||||
|
||||
if (status !== 'canceled') {
|
||||
emit('workflowExecuted');
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
const showWizard = computed(
|
||||
() =>
|
||||
!wizardFallback.value &&
|
||||
posthogStore.getVariant(AI_BUILDER_SETUP_WIZARD_EXPERIMENT.name) ===
|
||||
AI_BUILDER_SETUP_WIZARD_EXPERIMENT.variant,
|
||||
);
|
||||
|
||||
const hasValidationIssues = computed(() => builderStore.workflowTodos.length > 0);
|
||||
const triggerNodes = computed(() =>
|
||||
(workflowDocumentStore.value?.allNodes ?? []).filter((node) =>
|
||||
nodeTypesStore.isTriggerNode(node.type),
|
||||
),
|
||||
);
|
||||
const isReady = computed(() => !hasValidationIssues.value);
|
||||
|
||||
const {
|
||||
triggerNodes,
|
||||
availableTriggerNodes,
|
||||
executeButtonTooltip,
|
||||
isWorkflowRunning,
|
||||
isExecutionWaitingForWebhook,
|
||||
execute,
|
||||
} = useBuilderExecution(isReady);
|
||||
|
||||
const containerRef = ref<HTMLElement>();
|
||||
|
||||
const issuesByType = computed(() => {
|
||||
const credentials: WorkflowValidationIssue[] = [];
|
||||
@@ -151,20 +124,6 @@ function getNodeTypeByName(nodeName: string) {
|
||||
return nodeTypesStore.getNodeType(node.type);
|
||||
}
|
||||
|
||||
// Reactive workflow state
|
||||
const isWorkflowRunning = computed(() => workflowsStore.isWorkflowRunning);
|
||||
const isExecutionWaitingForWebhook = computed(() => workflowsStore.executionWaitingForWebhook);
|
||||
/**
|
||||
* Determines available trigger nodes for execution
|
||||
* Excludes trigger nodes when there are validation issues to prevent dropdown rendering
|
||||
*/
|
||||
const availableTriggerNodes = computed(() => (hasValidationIssues.value ? [] : triggerNodes.value));
|
||||
const executeButtonTooltip = computed(() =>
|
||||
hasValidationIssues.value
|
||||
? i18n.baseText('aiAssistant.builder.executeMessage.validationTooltip')
|
||||
: '',
|
||||
);
|
||||
|
||||
const showUnpinSection = computed(
|
||||
() =>
|
||||
builderStore.isCodeBuilder &&
|
||||
@@ -178,36 +137,7 @@ function onUnpinAll() {
|
||||
}
|
||||
|
||||
async function onExecute() {
|
||||
if (hasValidationIssues.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureExecutionWatcher();
|
||||
|
||||
const selectedTriggerNode =
|
||||
workflowsStore.selectedTriggerNodeName ?? availableTriggerNodes.value[0]?.name;
|
||||
const selectedTriggerNodeType = selectedTriggerNode
|
||||
? workflowDocumentStore.value?.getNodeByName(selectedTriggerNode)
|
||||
: null;
|
||||
|
||||
// If the selected trigger is a chat node, open logs panel instead of executing
|
||||
// the execution will be handled by the chat node itself
|
||||
if (selectedTriggerNodeType && isChatNode(selectedTriggerNodeType)) {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('aiAssistant.builder.toast.title'),
|
||||
message: i18n.baseText('aiAssistant.builder.toast.description'),
|
||||
type: 'info',
|
||||
});
|
||||
logsStore.toggleOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const runOptions: Parameters<typeof runWorkflow>[0] = {};
|
||||
if (selectedTriggerNode) {
|
||||
runOptions.triggerNode = selectedTriggerNode;
|
||||
}
|
||||
|
||||
await runWorkflow(runOptions);
|
||||
await execute(() => emit('workflowExecuted'));
|
||||
}
|
||||
|
||||
function scrollIntoView() {
|
||||
@@ -242,14 +172,16 @@ watch(hasValidationIssues, (hasIssues, hadIssues) => {
|
||||
builderStore.trackWorkflowBuilderJourney('no_placeholder_values_left');
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopExecutionWatcher();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BuilderSetupWizard
|
||||
v-if="showWizard"
|
||||
@workflow-executed="emit('workflowExecuted')"
|
||||
@no-setup-needed="wizardFallback = true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
ref="containerRef"
|
||||
:class="$style.container"
|
||||
role="region"
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { computed, onBeforeUnmount, nextTick, watch, type ComputedRef } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useLogsStore } from '@/app/stores/logs.store';
|
||||
import { useRunWorkflow } from '@/app/composables/useRunWorkflow';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { isChatNode } from '@/app/utils/aiUtils';
|
||||
|
||||
const RUNNING_STATES: string[] = ['running', 'waiting'];
|
||||
|
||||
/**
|
||||
* Shared execution logic for the builder setup wizard and control variant.
|
||||
* Handles trigger selection, chat-node detection, execution watcher, and cleanup.
|
||||
*
|
||||
* @param isReady - When false, the execute button is disabled and trigger dropdown is hidden.
|
||||
*/
|
||||
export function useBuilderExecution(isReady: ComputedRef<boolean>) {
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const logsStore = useLogsStore();
|
||||
const toast = useToast();
|
||||
|
||||
const { runWorkflow } = useRunWorkflow({ router });
|
||||
|
||||
const triggerNodes = computed(() =>
|
||||
workflowsStore.workflow.nodes.filter((node) => nodeTypesStore.isTriggerNode(node.type)),
|
||||
);
|
||||
|
||||
// Empty until ready — prevents trigger selection in the execute button while setup is pending
|
||||
const availableTriggerNodes = computed(() => (isReady.value ? triggerNodes.value : []));
|
||||
|
||||
const executeButtonTooltip = computed(() =>
|
||||
!isReady.value ? i18n.baseText('aiAssistant.builder.executeMessage.validationTooltip') : '',
|
||||
);
|
||||
|
||||
const isWorkflowRunning = computed(() => workflowsStore.isWorkflowRunning);
|
||||
const isExecutionWaitingForWebhook = computed(() => workflowsStore.executionWaitingForWebhook);
|
||||
|
||||
// --- Execution watcher ---
|
||||
let executionWatcherStop: (() => void) | undefined;
|
||||
|
||||
const stopExecutionWatcher = () => {
|
||||
if (executionWatcherStop) {
|
||||
executionWatcherStop();
|
||||
executionWatcherStop = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureExecutionWatcher = (onExecutionComplete: () => void) => {
|
||||
stopExecutionWatcher();
|
||||
|
||||
executionWatcherStop = watch(
|
||||
() => workflowsStore.workflowExecutionData?.status,
|
||||
async (status) => {
|
||||
await nextTick();
|
||||
if (!status || RUNNING_STATES.includes(status)) return;
|
||||
|
||||
stopExecutionWatcher();
|
||||
|
||||
if (status !== 'canceled') {
|
||||
onExecutionComplete();
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute the workflow. Returns false if execution was blocked (not ready, chat trigger).
|
||||
*/
|
||||
async function execute(onExecutionComplete: () => void): Promise<boolean> {
|
||||
if (!isReady.value) return false;
|
||||
|
||||
const selectedTriggerNode =
|
||||
workflowsStore.selectedTriggerNodeName ?? availableTriggerNodes.value[0]?.name;
|
||||
const selectedTriggerNodeType = selectedTriggerNode
|
||||
? workflowsStore.getNodeByName(selectedTriggerNode)
|
||||
: null;
|
||||
|
||||
ensureExecutionWatcher(onExecutionComplete);
|
||||
|
||||
if (selectedTriggerNodeType && isChatNode(selectedTriggerNodeType)) {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('aiAssistant.builder.toast.title'),
|
||||
message: i18n.baseText('aiAssistant.builder.toast.description'),
|
||||
type: 'info',
|
||||
});
|
||||
logsStore.toggleOpen(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
const runOptions: Parameters<typeof runWorkflow>[0] = {};
|
||||
if (selectedTriggerNode) {
|
||||
runOptions.triggerNode = selectedTriggerNode;
|
||||
}
|
||||
|
||||
await runWorkflow(runOptions);
|
||||
return true;
|
||||
}
|
||||
|
||||
onBeforeUnmount(stopExecutionWatcher);
|
||||
|
||||
return {
|
||||
triggerNodes,
|
||||
availableTriggerNodes,
|
||||
executeButtonTooltip,
|
||||
isWorkflowRunning,
|
||||
isExecutionWaitingForWebhook,
|
||||
execute,
|
||||
};
|
||||
}
|
||||
+499
@@ -0,0 +1,499 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { nextTick, reactive, ref, effectScope, type EffectScope } from 'vue';
|
||||
import { createTestNode } from '@/__tests__/mocks';
|
||||
import type { SetupCardItem, NodeSetupState } from '@/features/setupPanel/setupPanel.types';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
|
||||
const mockSetupCards = ref<SetupCardItem[]>([]);
|
||||
const mockFirstTriggerName = ref<string | null>(null);
|
||||
const mockSetCredential = vi.fn();
|
||||
const mockUnsetCredential = vi.fn();
|
||||
|
||||
const mockIsInitialCredentialTestingDone = ref(true);
|
||||
|
||||
vi.mock('@/features/setupPanel/composables/useWorkflowSetupState', () => ({
|
||||
useWorkflowSetupState: () => ({
|
||||
setupCards: mockSetupCards,
|
||||
firstTriggerName: mockFirstTriggerName,
|
||||
isInitialCredentialTestingDone: mockIsInitialCredentialTestingDone,
|
||||
setCredential: mockSetCredential,
|
||||
unsetCredential: mockUnsetCredential,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockTrackJourney = vi.fn();
|
||||
|
||||
// Reactive object so computed properties in the composable can track changes
|
||||
const mockBuilderStoreState = reactive({
|
||||
wizardCurrentStep: 0,
|
||||
wizardHasExecutedWorkflow: false,
|
||||
trackWorkflowBuilderJourney: mockTrackJourney,
|
||||
});
|
||||
vi.mock('@/features/ai/assistant/builder.store', () => ({
|
||||
useBuilderStore: () => mockBuilderStoreState,
|
||||
}));
|
||||
|
||||
const mockUnpinNodeData = vi.fn();
|
||||
const mockAllNodes = ref<INodeUi[]>([]);
|
||||
vi.mock('@/app/stores/workflows.store', () => ({
|
||||
useWorkflowsStore: () => ({
|
||||
workflowId: 'test-workflow-id',
|
||||
nodeMetadata: {} as Record<string, { pinnedDataLastRemovedAt?: number }>,
|
||||
get allNodes() {
|
||||
return mockAllNodes.value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/workflowDocument.store', () => ({
|
||||
useWorkflowDocumentStore: () => ({
|
||||
pinData: {},
|
||||
unpinNodeData: mockUnpinNodeData,
|
||||
}),
|
||||
createWorkflowDocumentId: (id: string) => id,
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/ui.store', () => ({
|
||||
useUIStore: () => ({
|
||||
markStateDirty: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUpdateNodeProperties = vi.fn();
|
||||
vi.mock('@/app/composables/useWorkflowState', () => ({
|
||||
injectWorkflowState: () => ({
|
||||
updateNodeProperties: mockUpdateNodeProperties,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useNodeHelpers', () => ({
|
||||
useNodeHelpers: () => ({
|
||||
updateNodesParameterIssues: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: () => ({}),
|
||||
}));
|
||||
|
||||
function createNode(overrides: Partial<INodeUi> = {}): INodeUi {
|
||||
return createTestNode({
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
...overrides,
|
||||
}) as INodeUi;
|
||||
}
|
||||
|
||||
function createCardState(overrides: Partial<NodeSetupState> = {}): NodeSetupState {
|
||||
return {
|
||||
node: createNode(),
|
||||
parameterIssues: {},
|
||||
isTrigger: false,
|
||||
isComplete: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createCard(stateOverrides: Partial<NodeSetupState> = {}): SetupCardItem {
|
||||
return { state: createCardState(stateOverrides) };
|
||||
}
|
||||
|
||||
import { useBuilderSetupCards } from './useBuilderSetupCards';
|
||||
|
||||
let currentScope: EffectScope | undefined;
|
||||
|
||||
function getComposable() {
|
||||
currentScope?.stop();
|
||||
currentScope = effectScope();
|
||||
return currentScope.run(() => useBuilderSetupCards())!;
|
||||
}
|
||||
|
||||
describe('useBuilderSetupCards', () => {
|
||||
beforeEach(() => {
|
||||
currentScope?.stop();
|
||||
currentScope = undefined;
|
||||
vi.clearAllMocks();
|
||||
mockSetupCards.value = [];
|
||||
mockAllNodes.value = [];
|
||||
mockFirstTriggerName.value = null;
|
||||
mockBuilderStoreState.wizardCurrentStep = 0;
|
||||
mockBuilderStoreState.wizardHasExecutedWorkflow = false;
|
||||
mockIsInitialCredentialTestingDone.value = true;
|
||||
});
|
||||
|
||||
it('passes through cards from useWorkflowSetupState', () => {
|
||||
// Manual trigger filtering is now handled upstream by useWorkflowSetupState
|
||||
mockSetupCards.value = [
|
||||
createCard({
|
||||
node: createNode({ type: 'n8n-nodes-base.httpRequest', name: 'HTTP Request' }),
|
||||
}),
|
||||
];
|
||||
|
||||
const { cards } = getComposable();
|
||||
expect(cards.value).toHaveLength(1);
|
||||
expect(cards.value[0].state.node.name).toBe('HTTP Request');
|
||||
});
|
||||
|
||||
it('returns correct navigation state', async () => {
|
||||
mockSetupCards.value = [
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 1', id: 'n1' }),
|
||||
credentialType: 'typeA',
|
||||
issues: ['Not set'],
|
||||
}),
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 2', id: 'n2' }),
|
||||
credentialType: 'typeB',
|
||||
issues: ['Not set'],
|
||||
}),
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 3', id: 'n3' }),
|
||||
credentialType: 'typeC',
|
||||
issues: ['Not set'],
|
||||
}),
|
||||
];
|
||||
|
||||
const { currentStepIndex, currentCard, totalCards, goToNext, goToPrev, goToStep } =
|
||||
getComposable();
|
||||
|
||||
expect(totalCards.value).toBe(3);
|
||||
expect(currentStepIndex.value).toBe(0);
|
||||
expect(currentCard.value?.state.node.name).toBe('Node 1');
|
||||
|
||||
goToNext();
|
||||
await nextTick();
|
||||
expect(currentStepIndex.value).toBe(1);
|
||||
|
||||
goToPrev();
|
||||
await nextTick();
|
||||
expect(currentStepIndex.value).toBe(0);
|
||||
|
||||
goToStep(2);
|
||||
await nextTick();
|
||||
expect(currentStepIndex.value).toBe(2);
|
||||
});
|
||||
|
||||
it('clamps step index when cards array shrinks', async () => {
|
||||
mockSetupCards.value = [
|
||||
createCard({ node: createNode({ name: 'Node 1', id: 'n1' }) }),
|
||||
createCard({ node: createNode({ name: 'Node 2', id: 'n2' }) }),
|
||||
createCard({ node: createNode({ name: 'Node 3', id: 'n3' }) }),
|
||||
];
|
||||
|
||||
const { currentStepIndex, goToStep } = getComposable();
|
||||
goToStep(2);
|
||||
await nextTick();
|
||||
expect(currentStepIndex.value).toBe(2);
|
||||
|
||||
mockSetupCards.value = [createCard({ node: createNode({ name: 'Node 1', id: 'n1' }) })];
|
||||
await nextTick();
|
||||
|
||||
expect(currentStepIndex.value).toBe(0);
|
||||
});
|
||||
|
||||
it('isAllComplete reflects all cards completion status', async () => {
|
||||
mockSetupCards.value = [
|
||||
createCard({ node: createNode({ name: 'Node 1', id: 'n1' }), isComplete: true }),
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 2', id: 'n2' }),
|
||||
isComplete: false,
|
||||
credentialType: 'telegramApi',
|
||||
issues: ['Credential not set'],
|
||||
}),
|
||||
];
|
||||
|
||||
const { isAllComplete } = getComposable();
|
||||
expect(isAllComplete.value).toBe(false);
|
||||
|
||||
mockSetupCards.value = [
|
||||
createCard({ node: createNode({ name: 'Node 1', id: 'n1' }), isComplete: true }),
|
||||
createCard({ node: createNode({ name: 'Node 2', id: 'n2' }), isComplete: true }),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(isAllComplete.value).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks continue telemetry', () => {
|
||||
mockSetupCards.value = [
|
||||
createCard({ node: createNode({ name: 'Node 1', id: 'n1' }) }),
|
||||
createCard({ node: createNode({ name: 'Node 2', id: 'n2' }) }),
|
||||
];
|
||||
|
||||
const { continueCurrent } = getComposable();
|
||||
continueCurrent();
|
||||
|
||||
expect(mockTrackJourney).toHaveBeenCalledWith(
|
||||
'setup_wizard_step_completed',
|
||||
expect.objectContaining({
|
||||
step: 1,
|
||||
total: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty cards when no setup cards exist', () => {
|
||||
mockSetupCards.value = [];
|
||||
|
||||
const { cards, isAllComplete, totalCards } = getComposable();
|
||||
expect(cards.value).toHaveLength(0);
|
||||
expect(totalCards.value).toBe(0);
|
||||
expect(isAllComplete.value).toBe(true);
|
||||
});
|
||||
|
||||
it('passes through setCredential and unsetCredential', () => {
|
||||
mockSetupCards.value = [createCard()];
|
||||
|
||||
const { setCredential, unsetCredential } = getComposable();
|
||||
setCredential('testType', 'testId', 'testNode');
|
||||
unsetCredential('testType', 'testNode');
|
||||
|
||||
expect(mockSetCredential).toHaveBeenCalledWith('testType', 'testId', 'testNode');
|
||||
expect(mockUnsetCredential).toHaveBeenCalledWith('testType', 'testNode');
|
||||
});
|
||||
|
||||
it('skips to first incomplete card on mount when current card is already complete', () => {
|
||||
mockSetupCards.value = [
|
||||
createCard({ node: createNode({ name: 'Node 1', id: 'n1' }), isComplete: true }),
|
||||
createCard({ node: createNode({ name: 'Node 2', id: 'n2' }), isComplete: true }),
|
||||
createCard({ node: createNode({ name: 'Node 3', id: 'n3' }), isComplete: false }),
|
||||
];
|
||||
|
||||
const { currentStepIndex } = getComposable();
|
||||
|
||||
// Should skip past completed cards to the first incomplete one
|
||||
expect(currentStepIndex.value).toBe(2);
|
||||
});
|
||||
|
||||
it('stays on step 0 on mount when first card is incomplete', () => {
|
||||
mockSetupCards.value = [
|
||||
createCard({ node: createNode({ name: 'Node 1', id: 'n1' }), isComplete: false }),
|
||||
createCard({ node: createNode({ name: 'Node 2', id: 'n2' }), isComplete: true }),
|
||||
];
|
||||
|
||||
const { currentStepIndex } = getComposable();
|
||||
|
||||
expect(currentStepIndex.value).toBe(0);
|
||||
});
|
||||
|
||||
it('stays on step 0 on mount when all cards are complete', () => {
|
||||
mockSetupCards.value = [
|
||||
createCard({ node: createNode({ name: 'Node 1', id: 'n1' }), isComplete: true }),
|
||||
createCard({ node: createNode({ name: 'Node 2', id: 'n2' }), isComplete: true }),
|
||||
];
|
||||
|
||||
const { currentStepIndex } = getComposable();
|
||||
|
||||
// All complete — no incomplete card to skip to, stays at 0
|
||||
expect(currentStepIndex.value).toBe(0);
|
||||
});
|
||||
|
||||
it('passes through trigger cards from useWorkflowSetupState without additional filtering', () => {
|
||||
mockSetupCards.value = [
|
||||
createCard({
|
||||
node: createNode({
|
||||
name: 'Webhook',
|
||||
id: 'trigger-1',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
}),
|
||||
isTrigger: true,
|
||||
isComplete: false,
|
||||
}),
|
||||
createCard({
|
||||
node: createNode({ name: 'HTTP Request', id: 'n2' }),
|
||||
credentialType: 'httpBasicAuth',
|
||||
isComplete: true,
|
||||
}),
|
||||
];
|
||||
|
||||
const { cards } = getComposable();
|
||||
expect(cards.value).toHaveLength(2);
|
||||
expect(cards.value[0].state.node.name).toBe('Webhook');
|
||||
expect(cards.value[1].state.node.name).toBe('HTTP Request');
|
||||
});
|
||||
|
||||
it('treats incomplete cards as genuinely incomplete', () => {
|
||||
mockSetupCards.value = [
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 1', id: 'n1' }),
|
||||
credentialType: 'telegramApi',
|
||||
selectedCredentialId: 'cred-1',
|
||||
issues: [],
|
||||
isComplete: false,
|
||||
}),
|
||||
];
|
||||
|
||||
const { isAllComplete } = getComposable();
|
||||
expect(isAllComplete.value).toBe(false);
|
||||
});
|
||||
|
||||
it('treats cards with no credential selected as genuinely incomplete', () => {
|
||||
mockSetupCards.value = [
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 1', id: 'n1' }),
|
||||
credentialType: 'telegramApi',
|
||||
selectedCredentialId: undefined,
|
||||
issues: ['Credential not set'],
|
||||
isComplete: false,
|
||||
}),
|
||||
];
|
||||
|
||||
const { isAllComplete } = getComposable();
|
||||
expect(isAllComplete.value).toBe(false);
|
||||
});
|
||||
|
||||
it('onStepExecuted dismisses wizard when all cards are complete', async () => {
|
||||
mockSetupCards.value = [
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 1', id: 'n1' }),
|
||||
credentialType: 'openAiApi',
|
||||
isComplete: true,
|
||||
}),
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 2', id: 'n2' }),
|
||||
credentialType: 'slackApi',
|
||||
isComplete: true,
|
||||
}),
|
||||
];
|
||||
// Start on the last card — onStepExecuted only dismisses from the last step
|
||||
mockBuilderStoreState.wizardCurrentStep = 1;
|
||||
|
||||
const { onStepExecuted } = getComposable();
|
||||
onStepExecuted();
|
||||
await nextTick();
|
||||
|
||||
expect(mockBuilderStoreState.wizardHasExecutedWorkflow).toBe(true);
|
||||
});
|
||||
|
||||
it('onStepExecuted does not dismiss wizard when some cards are incomplete', async () => {
|
||||
mockSetupCards.value = [
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 1', id: 'n1' }),
|
||||
credentialType: 'openAiApi',
|
||||
isComplete: true,
|
||||
}),
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 2', id: 'n2' }),
|
||||
credentialType: 'slackApi',
|
||||
isComplete: false,
|
||||
}),
|
||||
];
|
||||
|
||||
const { onStepExecuted } = getComposable();
|
||||
onStepExecuted();
|
||||
await nextTick();
|
||||
|
||||
expect(mockBuilderStoreState.wizardHasExecutedWorkflow).toBe(false);
|
||||
});
|
||||
|
||||
it('does not auto-advance when a card completes', async () => {
|
||||
mockSetupCards.value = [
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 1', id: 'n1' }),
|
||||
credentialType: 'openAiApi',
|
||||
isComplete: false,
|
||||
}),
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 2', id: 'n2' }),
|
||||
credentialType: 'slackApi',
|
||||
isComplete: false,
|
||||
}),
|
||||
];
|
||||
|
||||
const { currentStepIndex } = getComposable();
|
||||
expect(currentStepIndex.value).toBe(0);
|
||||
|
||||
// Simulate card completing — should stay on the same card
|
||||
mockSetupCards.value = [
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 1', id: 'n1' }),
|
||||
credentialType: 'openAiApi',
|
||||
isComplete: true,
|
||||
}),
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 2', id: 'n2' }),
|
||||
credentialType: 'slackApi',
|
||||
isComplete: false,
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(currentStepIndex.value).toBe(0);
|
||||
});
|
||||
|
||||
it('exposes isInitialCredentialTestingDone from useWorkflowSetupState', async () => {
|
||||
mockIsInitialCredentialTestingDone.value = false;
|
||||
const { isInitialCredentialTestingDone } = getComposable();
|
||||
expect(isInitialCredentialTestingDone.value).toBe(false);
|
||||
|
||||
mockIsInitialCredentialTestingDone.value = true;
|
||||
await nextTick();
|
||||
expect(isInitialCredentialTestingDone.value).toBe(true);
|
||||
});
|
||||
|
||||
it('skips to first incomplete card when initial credential testing completes', async () => {
|
||||
mockIsInitialCredentialTestingDone.value = false;
|
||||
// First card complete, second incomplete — but both have isComplete: false initially
|
||||
// because credential test hasn't resolved yet
|
||||
mockSetupCards.value = [
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 1', id: 'n1' }),
|
||||
credentialType: 'openAiApi',
|
||||
isComplete: false,
|
||||
}),
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 2', id: 'n2' }),
|
||||
credentialType: 'slackApi',
|
||||
isComplete: false,
|
||||
}),
|
||||
];
|
||||
|
||||
const { currentStepIndex } = getComposable();
|
||||
// While testing is in progress, stays on step 0 (card appears incomplete)
|
||||
expect(currentStepIndex.value).toBe(0);
|
||||
|
||||
// Simulate credential test resolving — first card becomes complete
|
||||
mockSetupCards.value = [
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 1', id: 'n1' }),
|
||||
credentialType: 'openAiApi',
|
||||
isComplete: true,
|
||||
}),
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 2', id: 'n2' }),
|
||||
credentialType: 'slackApi',
|
||||
isComplete: false,
|
||||
}),
|
||||
];
|
||||
mockIsInitialCredentialTestingDone.value = true;
|
||||
await nextTick();
|
||||
|
||||
// Should now skip to the first incomplete card
|
||||
expect(currentStepIndex.value).toBe(1);
|
||||
});
|
||||
|
||||
it('dismisses wizard when initial credential testing reveals all cards complete', async () => {
|
||||
mockIsInitialCredentialTestingDone.value = false;
|
||||
mockSetupCards.value = [
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 1', id: 'n1' }),
|
||||
credentialType: 'openAiApi',
|
||||
isComplete: true,
|
||||
}),
|
||||
createCard({
|
||||
node: createNode({ name: 'Node 2', id: 'n2' }),
|
||||
credentialType: 'slackApi',
|
||||
isComplete: true,
|
||||
}),
|
||||
];
|
||||
|
||||
getComposable();
|
||||
expect(mockBuilderStoreState.wizardHasExecutedWorkflow).toBe(false);
|
||||
|
||||
// Simulate credential tests finishing — all cards are complete
|
||||
mockIsInitialCredentialTestingDone.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(mockBuilderStoreState.wizardHasExecutedWorkflow).toBe(true);
|
||||
});
|
||||
});
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import type { SetupCardItem } from '@/features/setupPanel/setupPanel.types';
|
||||
import { useWorkflowSetupState } from '@/features/setupPanel/composables/useWorkflowSetupState';
|
||||
import { useBuilderStore } from '@/features/ai/assistant/builder.store';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import {
|
||||
useWorkflowDocumentStore,
|
||||
createWorkflowDocumentId,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import { findPlaceholderDetails } from '@/features/ai/assistant/composables/useBuilderTodos';
|
||||
|
||||
export function useBuilderSetupCards() {
|
||||
const builderStore = useBuilderStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const workflowDocumentStore = computed(() =>
|
||||
workflowsStore.workflowId
|
||||
? useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId))
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Sticky map of node name → placeholder parameter names.
|
||||
// Once a node's placeholders are detected, the entry persists even after the user
|
||||
// fills in the values. This prevents the card from reclassifying (nodeStates → credentialTypeStates)
|
||||
// when the user types, which would cause card instability and unwanted auto-advance.
|
||||
const stickyPlaceholderParams = ref(new Map<string, string[]>());
|
||||
|
||||
// Detect current placeholders (reactive, recomputes when node params change)
|
||||
const detectedPlaceholders = computed(() => {
|
||||
const result = new Map<string, string[]>();
|
||||
for (const node of workflowDocumentStore.value?.allNodes ?? []) {
|
||||
const placeholders = findPlaceholderDetails(node.parameters);
|
||||
if (placeholders.length > 0) {
|
||||
result.set(node.name, [...new Set(placeholders.map((p) => p.path[0]).filter(Boolean))]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Persist detected placeholder params into the sticky map via watcher (not inside computed)
|
||||
watch(
|
||||
detectedPlaceholders,
|
||||
(detected) => {
|
||||
for (const [name, params] of detected) {
|
||||
stickyPlaceholderParams.value.set(name, params);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Sticky map already accumulates all detected placeholders via the watcher above.
|
||||
// Wrapping in a computed ensures downstream consumers react to watcher updates.
|
||||
const placeholderParamsByNode = computed(() => stickyPlaceholderParams.value);
|
||||
|
||||
const {
|
||||
setupCards: baseCards,
|
||||
firstTriggerName,
|
||||
isInitialCredentialTestingDone,
|
||||
setCredential,
|
||||
unsetCredential,
|
||||
} = useWorkflowSetupState(undefined, {
|
||||
additionalParametersByNode: placeholderParamsByNode,
|
||||
});
|
||||
|
||||
// Step index is persisted in builder store
|
||||
const currentStepIndex = computed({
|
||||
get: () => builderStore.wizardCurrentStep,
|
||||
set: (val: number) => {
|
||||
builderStore.wizardCurrentStep = val;
|
||||
},
|
||||
});
|
||||
|
||||
const totalCards = computed(() => baseCards.value.length);
|
||||
|
||||
const currentCard = computed<SetupCardItem | undefined>(
|
||||
() => baseCards.value[currentStepIndex.value],
|
||||
);
|
||||
|
||||
const isAllComplete = computed(
|
||||
() =>
|
||||
isInitialCredentialTestingDone.value &&
|
||||
(baseCards.value.length === 0 || baseCards.value.every((card) => card.state.isComplete)),
|
||||
);
|
||||
|
||||
function skipToFirstIncomplete() {
|
||||
const current = baseCards.value[currentStepIndex.value];
|
||||
if (!current?.state.isComplete) return;
|
||||
const firstIncomplete = baseCards.value.findIndex((c) => !c.state.isComplete);
|
||||
if (firstIncomplete !== -1) {
|
||||
currentStepIndex.value = firstIncomplete;
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp step index when cards array changes, and on mount skip to
|
||||
// the first incomplete card (handles wizard remounting after AI update
|
||||
// with some cards already complete).
|
||||
watch(
|
||||
() => baseCards.value.length,
|
||||
(newLength) => {
|
||||
if (newLength === 0) {
|
||||
currentStepIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
if (currentStepIndex.value >= newLength) {
|
||||
currentStepIndex.value = newLength - 1;
|
||||
}
|
||||
skipToFirstIncomplete();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function goToNext() {
|
||||
if (currentStepIndex.value < baseCards.value.length - 1) {
|
||||
currentStepIndex.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function goToPrev() {
|
||||
if (currentStepIndex.value > 0) {
|
||||
currentStepIndex.value--;
|
||||
}
|
||||
}
|
||||
|
||||
function goToStep(index: number) {
|
||||
currentStepIndex.value = Math.max(0, Math.min(index, baseCards.value.length - 1));
|
||||
}
|
||||
|
||||
function continueCurrent() {
|
||||
builderStore.trackWorkflowBuilderJourney('setup_wizard_step_completed', {
|
||||
step: currentStepIndex.value + 1,
|
||||
total: totalCards.value,
|
||||
node_type: currentCard.value?.state.node.type,
|
||||
});
|
||||
goToNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a card's step execution finishes (trigger test or node execution).
|
||||
* Dismisses the wizard when all cards are complete.
|
||||
*/
|
||||
function onStepExecuted() {
|
||||
if (!currentCard.value?.state.isComplete) return;
|
||||
if (isAllComplete.value && currentStepIndex.value === totalCards.value - 1) {
|
||||
builderStore.wizardHasExecutedWorkflow = true;
|
||||
} else {
|
||||
goToNext();
|
||||
}
|
||||
}
|
||||
|
||||
// Track when all cards are complete
|
||||
watch(isAllComplete, (complete, wasComplete) => {
|
||||
if (complete && !wasComplete) {
|
||||
builderStore.trackWorkflowBuilderJourney('setup_wizard_all_complete', {
|
||||
total: totalCards.value,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// When initial credential tests finish, re-evaluate: skip past any cards that
|
||||
// turned out to be complete, or dismiss the wizard entirely.
|
||||
// immediate: true covers the no-credentials case where the value is already true at setup.
|
||||
watch(
|
||||
isInitialCredentialTestingDone,
|
||||
(done) => {
|
||||
if (!done) return;
|
||||
if (isAllComplete.value) {
|
||||
builderStore.wizardHasExecutedWorkflow = true;
|
||||
} else {
|
||||
skipToFirstIncomplete();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return {
|
||||
cards: baseCards,
|
||||
currentStepIndex,
|
||||
currentCard,
|
||||
isAllComplete,
|
||||
isInitialCredentialTestingDone,
|
||||
totalCards,
|
||||
firstTriggerName,
|
||||
setCredential,
|
||||
unsetCredential,
|
||||
goToNext,
|
||||
goToPrev,
|
||||
goToStep,
|
||||
continueCurrent,
|
||||
onStepExecuted,
|
||||
};
|
||||
}
|
||||
@@ -780,6 +780,7 @@ async function loadRemoteParameterOptions() {
|
||||
const resolvedNodeParameters = (await workflowHelpers.resolveRequiredParameters(
|
||||
props.parameter,
|
||||
currentNodeParameters,
|
||||
expressionLocalResolveCtx?.value ?? {},
|
||||
)) as INodeParameters;
|
||||
const loadOptionsMethod = getTypeOption('loadOptionsMethod');
|
||||
const loadOptions = getTypeOption('loadOptions');
|
||||
|
||||
@@ -96,10 +96,10 @@ watch(
|
||||
const key = cardKey(card);
|
||||
if (cardsWithParameters.has(key)) continue;
|
||||
|
||||
const templateParamNames = card.state.templateParameterNames ?? [];
|
||||
const additionalParamNames = card.state.additionalParameterNames ?? [];
|
||||
const issueParamNames = Object.keys(card.state.parameterIssues);
|
||||
|
||||
if (templateParamNames.length > 0 || issueParamNames.length > 0) {
|
||||
if (additionalParamNames.length > 0 || issueParamNames.length > 0) {
|
||||
cardsWithParameters.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -101,8 +101,8 @@ const parameters = computed<INodeProperties[]>(() => {
|
||||
if (!nodeType.value?.properties) return [];
|
||||
|
||||
const issueParamNames = Object.keys(props.state.parameterIssues);
|
||||
const templateParamNames = props.state.templateParameterNames ?? [];
|
||||
const allParamNames = new Set([...issueParamNames, ...templateParamNames]);
|
||||
const additionalParamNames = props.state.additionalParameterNames ?? [];
|
||||
const allParamNames = new Set([...issueParamNames, ...additionalParamNames]);
|
||||
|
||||
for (const prop of nodeType.value.properties) {
|
||||
if (allParamNames.has(prop.name) && !shownParameters.value.includes(prop)) {
|
||||
@@ -201,7 +201,7 @@ const allParametersAddressed = computed(
|
||||
allNodeIssuesResolved.value ||
|
||||
// When template parameters exist but there are no current issues,
|
||||
// the user already configured them in a previous session
|
||||
((props.state.templateParameterNames?.length ?? 0) > 0 &&
|
||||
((props.state.additionalParameterNames?.length ?? 0) > 0 &&
|
||||
Object.keys(props.state.parameterIssues).length === 0),
|
||||
);
|
||||
|
||||
|
||||
@@ -83,6 +83,12 @@ export function useWebhookUrls(node: MaybeRef<INodeUi | null>) {
|
||||
const currentNode = nodeValue.value;
|
||||
if (!currentNode || webhooks.value.length === 0) return [];
|
||||
|
||||
// Access parameters synchronously so Vue tracks it as a dependency.
|
||||
// Without this, changing a node parameter (e.g. webhook path) won't
|
||||
// trigger a URL recomputation because the node object reference itself
|
||||
// doesn't change — only its `parameters` property does.
|
||||
void currentNode.parameters;
|
||||
|
||||
const result: WebhookDisplayData[] = [];
|
||||
|
||||
for (const webhook of webhooks.value) {
|
||||
|
||||
+80
-22
@@ -1,4 +1,4 @@
|
||||
import { ref, shallowRef, nextTick } from 'vue';
|
||||
import { ref, nextTick } from 'vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
import { createTestNode } from '@/__tests__/mocks';
|
||||
@@ -39,7 +39,8 @@ vi.mock('@/app/stores/workflowDocument.store', async () => {
|
||||
const actual = await vi.importActual('@/app/stores/workflowDocument.store');
|
||||
return {
|
||||
...actual,
|
||||
injectWorkflowDocumentStore: vi.fn(() => shallowRef(mockWorkflowDocumentStore)),
|
||||
useWorkflowDocumentStore: vi.fn(() => mockWorkflowDocumentStore),
|
||||
createWorkflowDocumentId: vi.fn().mockReturnValue('test-id'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -96,6 +97,7 @@ describe('useWorkflowSetupState', () => {
|
||||
credentialsStore = mockedStore(useCredentialsStore);
|
||||
nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
|
||||
workflowsStore.workflowId = 'test-workflow';
|
||||
credentialsStore.getCredentialTypeByName = vi.fn().mockReturnValue(undefined);
|
||||
credentialsStore.getCredentialById = vi.fn().mockReturnValue(undefined);
|
||||
credentialsStore.getNodesWithAccess = vi.fn().mockReturnValue([]);
|
||||
@@ -132,6 +134,7 @@ describe('useWorkflowSetupState', () => {
|
||||
});
|
||||
mockWorkflowDocumentStore.allNodes = [triggerNode];
|
||||
nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(true);
|
||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue({ webhooks: [{}] });
|
||||
|
||||
const { setupCards } = useWorkflowSetupState();
|
||||
|
||||
@@ -273,11 +276,12 @@ describe('useWorkflowSetupState', () => {
|
||||
|
||||
it('should produce only trigger card for trigger without credentials', () => {
|
||||
const triggerNode = createNode({
|
||||
name: 'ScheduleTrigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
});
|
||||
mockWorkflowDocumentStore.allNodes = [triggerNode];
|
||||
nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(true);
|
||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue({ webhooks: [{}] });
|
||||
|
||||
const { setupCards } = useWorkflowSetupState();
|
||||
|
||||
@@ -289,8 +293,9 @@ describe('useWorkflowSetupState', () => {
|
||||
it('should sort cards by primary node execution order, interleaving credential and trigger cards', () => {
|
||||
// Trigger comes before regular node in execution order
|
||||
const triggerNode = createNode({
|
||||
name: 'ScheduleTrigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
|
||||
position: [0, 0],
|
||||
});
|
||||
const regularNode = createNode({
|
||||
@@ -299,9 +304,9 @@ describe('useWorkflowSetupState', () => {
|
||||
position: [100, 0],
|
||||
});
|
||||
mockWorkflowDocumentStore.allNodes = [triggerNode, regularNode];
|
||||
nodeTypesStore.isTriggerNode = vi.fn(
|
||||
(type: string) => type === 'n8n-nodes-base.scheduleTrigger',
|
||||
);
|
||||
nodeTypesStore.isTriggerNode = vi.fn((type: string) => type === 'n8n-nodes-base.webhook');
|
||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue({ webhooks: [{}] });
|
||||
|
||||
mockGetNodeTypeDisplayableCredentials.mockImplementation((_store, node) => {
|
||||
if ((node as INodeUi).name === 'Regular') return [{ name: 'testApi' }];
|
||||
return [];
|
||||
@@ -311,13 +316,15 @@ describe('useWorkflowSetupState', () => {
|
||||
});
|
||||
mockWorkflowDocumentStore.getNodeByName = vi.fn((name: string) => {
|
||||
if (name === 'Regular') return regularNode;
|
||||
if (name === 'ScheduleTrigger') return triggerNode;
|
||||
if (name === 'Webhook') return triggerNode;
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const { setupCards } = useWorkflowSetupState();
|
||||
|
||||
// Trigger card first (ScheduleTrigger at index 0 in execution order),
|
||||
// Trigger card first (Webhook at index 0 in execution order),
|
||||
|
||||
// then credential card (Regular at index 1)
|
||||
expect(setupCards.value).toHaveLength(2);
|
||||
expect(setupCards.value[0].state.isTrigger).toBe(true);
|
||||
@@ -518,11 +525,12 @@ describe('useWorkflowSetupState', () => {
|
||||
describe('triggerStates', () => {
|
||||
it('should mark trigger without credentials as complete after execution', () => {
|
||||
const triggerNode = createNode({
|
||||
name: 'ScheduleTrigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
});
|
||||
mockWorkflowDocumentStore.allNodes = [triggerNode];
|
||||
nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(true);
|
||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue({ webhooks: [{}] });
|
||||
workflowsStore.getWorkflowResultDataByNodeName = vi.fn().mockReturnValue([{ data: {} }]);
|
||||
|
||||
const { triggerStates } = useWorkflowSetupState();
|
||||
@@ -552,17 +560,65 @@ describe('useWorkflowSetupState', () => {
|
||||
|
||||
it('should mark trigger without credentials and no execution as incomplete', () => {
|
||||
const triggerNode = createNode({
|
||||
name: 'ScheduleTrigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
});
|
||||
mockWorkflowDocumentStore.allNodes = [triggerNode];
|
||||
nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(true);
|
||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue({ webhooks: [{}] });
|
||||
workflowsStore.getWorkflowResultDataByNodeName = vi.fn().mockReturnValue(null);
|
||||
|
||||
const { triggerStates } = useWorkflowSetupState();
|
||||
|
||||
expect(triggerStates.value[0].isComplete).toBe(false);
|
||||
});
|
||||
|
||||
it('should not create standalone card for trigger that does not wait for external input', () => {
|
||||
const triggerNode = createNode({
|
||||
name: 'ScheduleTrigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
});
|
||||
mockWorkflowDocumentStore.allNodes = [triggerNode];
|
||||
nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(true);
|
||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue({});
|
||||
workflowsStore.getWorkflowResultDataByNodeName = vi.fn().mockReturnValue(null);
|
||||
|
||||
const { triggerStates } = useWorkflowSetupState();
|
||||
|
||||
expect(triggerStates.value).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should create standalone card for polling trigger', () => {
|
||||
const triggerNode = createNode({
|
||||
name: 'RssFeedTrigger',
|
||||
type: 'n8n-nodes-base.rssFeedReadTrigger',
|
||||
});
|
||||
mockWorkflowDocumentStore.allNodes = [triggerNode];
|
||||
nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(true);
|
||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue({ polling: true });
|
||||
workflowsStore.getWorkflowResultDataByNodeName = vi.fn().mockReturnValue(null);
|
||||
|
||||
const { triggerStates } = useWorkflowSetupState();
|
||||
|
||||
expect(triggerStates.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should create standalone card for trigger with triggerPanel', () => {
|
||||
const triggerNode = createNode({
|
||||
name: 'LocalFileTrigger',
|
||||
type: 'n8n-nodes-base.localFileTrigger',
|
||||
});
|
||||
mockWorkflowDocumentStore.allNodes = [triggerNode];
|
||||
nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(true);
|
||||
nodeTypesStore.getNodeType = vi
|
||||
.fn()
|
||||
.mockReturnValue({ triggerPanel: { header: 'Listening' } });
|
||||
workflowsStore.getWorkflowResultDataByNodeName = vi.fn().mockReturnValue(null);
|
||||
|
||||
const { triggerStates } = useWorkflowSetupState();
|
||||
|
||||
expect(triggerStates.value).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCredential', () => {
|
||||
@@ -1078,8 +1134,9 @@ describe('useWorkflowSetupState', () => {
|
||||
|
||||
it('should return total number of setup cards', () => {
|
||||
const triggerNode = createNode({
|
||||
name: 'ScheduleTrigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
|
||||
position: [0, 0],
|
||||
});
|
||||
const regularNode = createNode({
|
||||
@@ -1088,9 +1145,9 @@ describe('useWorkflowSetupState', () => {
|
||||
position: [100, 0],
|
||||
});
|
||||
mockWorkflowDocumentStore.allNodes = [triggerNode, regularNode];
|
||||
nodeTypesStore.isTriggerNode = vi.fn(
|
||||
(type: string) => type === 'n8n-nodes-base.scheduleTrigger',
|
||||
);
|
||||
nodeTypesStore.isTriggerNode = vi.fn((type: string) => type === 'n8n-nodes-base.webhook');
|
||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue({ webhooks: [{}] });
|
||||
|
||||
mockGetNodeTypeDisplayableCredentials.mockImplementation((_store, node) => {
|
||||
if ((node as INodeUi).name === 'Regular') return [{ name: 'testApi' }];
|
||||
return [];
|
||||
@@ -1099,14 +1156,14 @@ describe('useWorkflowSetupState', () => {
|
||||
displayName: 'Test',
|
||||
});
|
||||
mockWorkflowDocumentStore.getNodeByName = vi.fn((name: string) => {
|
||||
if (name === 'ManualTrigger') return triggerNode;
|
||||
if (name === 'Webhook') return triggerNode;
|
||||
if (name === 'Regular') return regularNode;
|
||||
return null;
|
||||
});
|
||||
|
||||
const { totalCardsRequiringSetup } = useWorkflowSetupState();
|
||||
|
||||
// 1 standalone trigger card (no credentials) + 1 credential card
|
||||
// 1 standalone trigger card (webhook, no credentials) + 1 credential card
|
||||
expect(totalCardsRequiringSetup.value).toBe(2);
|
||||
});
|
||||
|
||||
@@ -1790,6 +1847,7 @@ describe('useWorkflowSetupState', () => {
|
||||
});
|
||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue({
|
||||
properties: [{ name: 'url', required: true }],
|
||||
webhooks: [{}],
|
||||
});
|
||||
nodeTypesStore.isTriggerNode = vi.fn((type: string) => type === 'n8n-nodes-base.webhook');
|
||||
|
||||
|
||||
+243
-167
@@ -16,16 +16,25 @@ import {
|
||||
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useEnvironmentsStore } from '@/features/settings/environments.ee/environments.store';
|
||||
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
import {
|
||||
useWorkflowDocumentStore,
|
||||
createWorkflowDocumentId,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
|
||||
import {
|
||||
getNodeCredentialTypes,
|
||||
groupCredentialsByType,
|
||||
isCredentialCardComplete,
|
||||
isNodeSetupComplete,
|
||||
isHttpRequestNodeType,
|
||||
buildTriggerSetupState,
|
||||
getNodeParametersIssues,
|
||||
type CompletionContext,
|
||||
} from '@/features/setupPanel/setupPanel.utils';
|
||||
import {
|
||||
isPlaceholderValue,
|
||||
findPlaceholderDetails,
|
||||
} from '@/features/ai/assistant/composables/useBuilderTodos';
|
||||
import { PLACEHOLDER_FILLED_AT_EXECUTION_TIME, MANUAL_TRIGGER_NODE_TYPE } from '@/app/constants';
|
||||
|
||||
import { sortNodesByExecutionOrder } from '@/app/utils/workflowUtils';
|
||||
@@ -38,16 +47,27 @@ import { useTemplatesStore } from '@/features/workflows/templates/templates.stor
|
||||
* with trigger nodes getting their own dedicated cards (test button only).
|
||||
* @param nodes Optional sub-set of nodes to check (defaults to full workflow)
|
||||
*/
|
||||
export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
export const useWorkflowSetupState = (
|
||||
nodes?: Ref<INodeUi[]>,
|
||||
options?: {
|
||||
/** Additional parameter names per node that should be shown in setup cards
|
||||
* (e.g., parameters with placeholder values from the AI builder). */
|
||||
additionalParametersByNode?: Ref<Map<string, string[]>>;
|
||||
},
|
||||
) => {
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const environmentsStore = useEnvironmentsStore();
|
||||
const templatesStore = useTemplatesStore();
|
||||
const workflowDocumentStore = injectWorkflowDocumentStore();
|
||||
const workflowDocumentStore = computed(() =>
|
||||
workflowsStore.workflowId
|
||||
? useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId))
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const sourceNodes = computed(() => nodes?.value ?? workflowDocumentStore?.value?.allNodes ?? []);
|
||||
const sourceNodes = computed(() => nodes?.value ?? workflowDocumentStore.value?.allNodes ?? []);
|
||||
|
||||
/**
|
||||
* Synchronous: detects resource locator parameters from the current workflow
|
||||
@@ -98,22 +118,42 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
|
||||
/**
|
||||
* Combined map of node name → parameter names that should always be shown.
|
||||
* Merges synchronous resource locator detection with async template results.
|
||||
* Merges synchronous resource locator detection, async template results,
|
||||
* and any additional parameters (e.g., placeholder params from the builder).
|
||||
*/
|
||||
const templateParametersByNode = computed(() => {
|
||||
const merged = new Map<string, string[]>();
|
||||
|
||||
const mergeInto = (source: Map<string, string[]>) => {
|
||||
for (const [nodeName, params] of source) {
|
||||
const existing = merged.get(nodeName);
|
||||
if (existing) {
|
||||
const combined = new Set([...existing, ...params]);
|
||||
merged.set(nodeName, Array.from(combined));
|
||||
} else {
|
||||
merged.set(nodeName, [...params]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const [nodeName, params] of resourceLocatorsByNode.value) {
|
||||
merged.set(nodeName, [...params]);
|
||||
}
|
||||
|
||||
for (const [nodeName, params] of templateMissingParams.value) {
|
||||
mergeInto(templateMissingParams.value);
|
||||
|
||||
if (options?.additionalParametersByNode?.value) {
|
||||
mergeInto(options.additionalParametersByNode.value);
|
||||
}
|
||||
|
||||
// Merge sticky issue param names so parameters persist after issues are resolved
|
||||
for (const [nodeName, paramNames] of stickyIssueParamNames.value) {
|
||||
const existing = merged.get(nodeName);
|
||||
if (existing) {
|
||||
const combined = new Set([...existing, ...params]);
|
||||
const combined = new Set([...existing, ...paramNames]);
|
||||
merged.set(nodeName, Array.from(combined));
|
||||
} else {
|
||||
merged.set(nodeName, [...params]);
|
||||
merged.set(nodeName, Array.from(paramNames));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +175,19 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
const paramNamesToCheck = new Set(templateParams);
|
||||
const findUnfilled = (obj: Record<string, unknown>): boolean => {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (paramNamesToCheck.has(key) && isResourceLocatorValue(value) && !value.value) {
|
||||
return true;
|
||||
if (paramNamesToCheck.has(key)) {
|
||||
if (isResourceLocatorValue(value)) {
|
||||
if (!value.value || isPlaceholderValue(value.value)) return true;
|
||||
} else if (
|
||||
value === '' ||
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
isPlaceholderValue(value)
|
||||
) {
|
||||
return true;
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
if (findPlaceholderDetails(value).length > 0) return true;
|
||||
}
|
||||
}
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
if (findUnfilled(value as Record<string, unknown>)) return true;
|
||||
@@ -243,16 +294,28 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Sticky tracking sets (reactive) ---
|
||||
// These ensure cards don't disappear once shown, even when issues resolve temporarily.
|
||||
// Updated via watchers instead of inside computed getters to avoid side effects.
|
||||
const stickyNodeIds = ref(new Set<string>());
|
||||
const stickyParamNodeIds = ref(new Set<string>());
|
||||
const stickyNodeCredentials = ref(new Set<string>());
|
||||
const stickyCredTypesWithParams = ref(new Set<string>());
|
||||
|
||||
/**
|
||||
* Tracks node IDs that have been shown in setup cards at least once.
|
||||
* Prevents cards from disappearing when nodes are temporarily valid.
|
||||
* Tracks parameter names that have appeared in parameterIssues per node (keyed by node name).
|
||||
* Once a parameter name is recorded, it persists even after the user fills it in
|
||||
* (which resolves the issue). Merged into `templateParametersByNode` so the existing
|
||||
* infrastructure (nodeHasTemplateParams, hasUnfilledTemplateParams, additionalParameterNames)
|
||||
* all work consistently.
|
||||
*/
|
||||
const seenNodes = new Set<string>();
|
||||
const stickyIssueParamNames = ref(new Map<string, Set<string>>());
|
||||
|
||||
/**
|
||||
* Get nodes that require setup:
|
||||
* - Nodes with credential requirements
|
||||
* - Trigger nodes (regardless of credentials)
|
||||
* - Nodes previously shown (sticky)
|
||||
* Sorted by execution order (grouped by trigger, DFS through connections).
|
||||
*/
|
||||
const nodesRequiringSetup = computed(() => {
|
||||
@@ -266,17 +329,13 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
}))
|
||||
.filter(
|
||||
({ credentialTypes, isTrigger, parameterIssues, node }) =>
|
||||
seenNodes.has(node.id) ||
|
||||
stickyNodeIds.value.has(node.id) ||
|
||||
credentialTypes.length > 0 ||
|
||||
isTrigger ||
|
||||
Object.keys(parameterIssues).length > 0 ||
|
||||
nodeHasTemplateParams(node.name),
|
||||
);
|
||||
|
||||
// Never remove entries once we show them
|
||||
for (const { node } of nodesForSetup) {
|
||||
seenNodes.add(node.id);
|
||||
}
|
||||
return sortNodesByExecutionOrder(
|
||||
nodesForSetup,
|
||||
workflowsStore.connectionsBySourceNode,
|
||||
@@ -284,6 +343,27 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
);
|
||||
});
|
||||
|
||||
// Persist node IDs and parameter issue names once shown so cards don't disappear
|
||||
watch(
|
||||
nodesRequiringSetup,
|
||||
(entries) => {
|
||||
for (const { node, parameterIssues } of entries) {
|
||||
stickyNodeIds.value.add(node.id);
|
||||
|
||||
const issueNames = Object.keys(parameterIssues);
|
||||
if (issueNames.length > 0) {
|
||||
const existing = stickyIssueParamNames.value.get(node.name);
|
||||
if (existing) {
|
||||
for (const name of issueNames) existing.add(name);
|
||||
} else {
|
||||
stickyIssueParamNames.value.set(node.name, new Set(issueNames));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* The name of the first (leftmost) trigger in the workflow.
|
||||
* Only this trigger can be executed from setup cards; others are treated as regular nodes.
|
||||
@@ -295,70 +375,84 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
|
||||
/**
|
||||
* All nodes that have credential requirements (includes both triggers and regular nodes).
|
||||
* Sorted by X position.
|
||||
*/
|
||||
const nodesWithCredentials = computed(() =>
|
||||
nodesRequiringSetup.value.filter(({ credentialTypes }) => credentialTypes.length > 0),
|
||||
);
|
||||
|
||||
const seenParameterNodes = new Set<string>();
|
||||
|
||||
const nodesWithMissingParameters = computed(() => {
|
||||
const result = nodesRequiringSetup.value.filter(
|
||||
const nodesWithMissingParameters = computed(() =>
|
||||
nodesRequiringSetup.value.filter(
|
||||
({ parameterIssues, node }) =>
|
||||
seenParameterNodes.has(node.id) ||
|
||||
stickyParamNodeIds.value.has(node.id) ||
|
||||
Object.keys(parameterIssues).length > 0 ||
|
||||
nodeHasTemplateParams(node.name),
|
||||
);
|
||||
),
|
||||
);
|
||||
|
||||
for (const { node } of result) {
|
||||
seenParameterNodes.add(node.id);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
// Persist parameter node IDs once shown
|
||||
watch(
|
||||
nodesWithMissingParameters,
|
||||
(entries) => {
|
||||
for (const { node } of entries) {
|
||||
stickyParamNodeIds.value.add(node.id);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* Tracks node-credential combinations (format: "credType:nodeId") that have been shown at least once.
|
||||
* Prevents cards from disappearing after parameters are filled.
|
||||
* Shared classification: which credential types require per-node cards?
|
||||
* A credential type is per-node when ANY of its nodes have parameter issues or template params.
|
||||
* Also includes historically-seen types to prevent cards from re-grouping.
|
||||
*/
|
||||
const seenNodeCredentials = new Set<string>();
|
||||
|
||||
/**
|
||||
* Tracks credential types that have ever had nodes with parameter issues.
|
||||
* Once a credential type is tracked here, it's handled by nodeStates instead of credentialTypeStates.
|
||||
* This prevents duplicate cards when parameters are filled but ensures the card persists.
|
||||
*/
|
||||
const seenCredentialTypesWithParameters = new Set<string>();
|
||||
|
||||
/**
|
||||
* Credential type states — one entry per unique credential type.
|
||||
* Ordered by leftmost node X position (inherited from nodesWithCredentials iteration order).
|
||||
* Cards with embedded triggers have isComplete recomputed to include trigger execution.
|
||||
*
|
||||
* NOTE: This now only includes credential types where NONE of the nodes have parameter issues.
|
||||
* When nodes have both credentials and parameters, they're handled by nodeStates instead.
|
||||
*/
|
||||
const credentialTypeStates = computed(() => {
|
||||
// First, identify which credential types have ANY nodes with parameter issues or template parameters
|
||||
const credentialTypesWithParameters = new Set<string>();
|
||||
const perNodeCredTypes = computed(() => {
|
||||
const result = new Set(stickyCredTypesWithParams.value);
|
||||
for (const { credentialTypes, parameterIssues, node } of nodesRequiringSetup.value) {
|
||||
if (Object.keys(parameterIssues).length > 0 || nodeHasTemplateParams(node.name)) {
|
||||
for (const credType of credentialTypes) {
|
||||
credentialTypesWithParameters.add(credType);
|
||||
result.add(credType);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Only group credential types that have NO nodes with parameter issues
|
||||
// AND that have never been seen with parameters (to prevent duplication with nodeCredentialStates)
|
||||
// Persist per-node credential types once seen
|
||||
watch(
|
||||
perNodeCredTypes,
|
||||
(types) => {
|
||||
for (const credType of types) {
|
||||
stickyCredTypesWithParams.value.add(credType);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* Builds a CompletionContext. When credentialType is provided and testable,
|
||||
* includes the credential test check; otherwise omits it.
|
||||
*/
|
||||
const buildCompletionContext = (credentialType?: string): CompletionContext => ({
|
||||
firstTriggerName: firstTriggerName.value,
|
||||
hasTriggerExecuted: hasTriggerExecutedSuccessfully,
|
||||
isTriggerNode: (nodeType: string) => nodeTypesStore.isTriggerNode(nodeType),
|
||||
isCredentialTestedOk:
|
||||
credentialType && isCredentialTypeTestable(credentialType)
|
||||
? credentialsStore.isCredentialTestedOk
|
||||
: undefined,
|
||||
hasUnfilledTemplateParams,
|
||||
});
|
||||
|
||||
/**
|
||||
* Credential type states — one entry per unique credential type.
|
||||
* Only includes credential types where NONE of the nodes have parameter issues.
|
||||
* When nodes have both credentials and parameters, they're handled by nodeStates instead.
|
||||
* Uses shared `perNodeCredTypes` for the grouping decision.
|
||||
*/
|
||||
const credentialTypeStates = computed(() => {
|
||||
const nodesWithoutParameters = nodesWithCredentials.value.filter(
|
||||
({ credentialTypes }) =>
|
||||
!credentialTypes.some(
|
||||
(credType) =>
|
||||
credentialTypesWithParameters.has(credType) ||
|
||||
seenCredentialTypesWithParameters.has(credType),
|
||||
),
|
||||
!credentialTypes.some((credType) => perNodeCredTypes.value.has(credType)),
|
||||
);
|
||||
|
||||
const grouped = groupCredentialsByType(
|
||||
@@ -369,35 +463,16 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
getCredentialDisplayName,
|
||||
resolveExpressionUrl,
|
||||
);
|
||||
// Only the workflow's first trigger (leftmost) can be executed from setup cards.
|
||||
// It gets an embedded execute button and affects card completion.
|
||||
// Other triggers are treated as regular nodes (credentials only, no execute).
|
||||
const isTriggerNodeType = (nodeType: string) => nodeTypesStore.isTriggerNode(nodeType);
|
||||
|
||||
return grouped.map((state) => {
|
||||
const embeddedTrigger = state.nodes.find(
|
||||
(node) => isTriggerNode(node) && node.name === firstTriggerName.value,
|
||||
);
|
||||
// For completion check, only consider the embedded (first) trigger
|
||||
const nodesForCompletion = embeddedTrigger
|
||||
? state.nodes.filter((node) => !isTriggerNode(node) || node === embeddedTrigger)
|
||||
: state.nodes.filter((node) => !isTriggerNode(node));
|
||||
// Only require a passing test for credential types that actually have a test mechanism.
|
||||
// Non-testable types (e.g. header auth) are complete when just set.
|
||||
const testChecker = isCredentialTypeTestable(state.credentialType)
|
||||
? credentialsStore.isCredentialTestedOk
|
||||
: undefined;
|
||||
const baseComplete = isCredentialCardComplete(
|
||||
{ ...state, nodes: nodesForCompletion },
|
||||
hasTriggerExecutedSuccessfully,
|
||||
isTriggerNodeType,
|
||||
testChecker,
|
||||
);
|
||||
const isAutoApplied =
|
||||
!!state.selectedCredentialId &&
|
||||
autoAppliedCredentialIds.value.has(state.selectedCredentialId);
|
||||
|
||||
return {
|
||||
...state,
|
||||
isComplete: baseComplete,
|
||||
isComplete: isCredentialCardComplete(state, buildCompletionContext(state.credentialType)),
|
||||
|
||||
isAutoApplied,
|
||||
};
|
||||
});
|
||||
@@ -405,39 +480,53 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
|
||||
/**
|
||||
* Trigger states — one entry per trigger node that is NOT already covered
|
||||
* by a credential card (credentialTypeStates) or a node card (nodeStates).
|
||||
* by a credential card or a node card.
|
||||
* Uses `perNodeCredTypes` to check node-card coverage without depending on `nodeStates`.
|
||||
*/
|
||||
const triggerStates = computed(() => {
|
||||
// Only the first trigger can get a standalone trigger card.
|
||||
if (!firstTriggerName.value) return [];
|
||||
|
||||
// Check if it's already covered by a credential-type card or a node card.
|
||||
const triggerEntry = nodesRequiringSetup.value.find(
|
||||
({ isTrigger, node }) => isTrigger && node.name === firstTriggerName.value,
|
||||
);
|
||||
if (!triggerEntry) return [];
|
||||
|
||||
// Check if covered by a grouped credential card
|
||||
const isInCredentialCards = credentialTypeStates.value.some((credState) =>
|
||||
credState.nodes.some((node) => isTriggerNode(node) && node.name === firstTriggerName.value),
|
||||
);
|
||||
const isInNodeCards = nodeStates.value.some(
|
||||
(state) => state.node.name === firstTriggerName.value,
|
||||
);
|
||||
if (isInCredentialCards || isInNodeCards) return [];
|
||||
if (isInCredentialCards) return [];
|
||||
|
||||
return nodesRequiringSetup.value
|
||||
.filter(({ isTrigger, node }) => isTrigger && node.name === firstTriggerName.value)
|
||||
.map(({ node, credentialTypes }) =>
|
||||
buildTriggerSetupState(
|
||||
node,
|
||||
credentialTypes,
|
||||
credentialTypeStates.value,
|
||||
hasTriggerExecutedSuccessfully(node.name),
|
||||
),
|
||||
);
|
||||
// Check if covered by a per-node card (has creds in a per-node cred type, or has params)
|
||||
const { credentialTypes, parameterIssues, node } = triggerEntry;
|
||||
const hasParams = Object.keys(parameterIssues).length > 0 || nodeHasTemplateParams(node.name);
|
||||
const hasPerNodeCreds =
|
||||
credentialTypes.length > 0 && credentialTypes.some((ct) => perNodeCredTypes.value.has(ct));
|
||||
if (hasPerNodeCreds || hasParams) return [];
|
||||
|
||||
// Skip triggers that don't wait for external input (e.g. Manual, Schedule).
|
||||
// Only show a standalone trigger card when the trigger listens for webhooks,
|
||||
// polls an external source, or has a triggerPanel (event-listening triggers).
|
||||
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
if (!nodeType?.webhooks?.length && !nodeType?.polling && !nodeType?.triggerPanel) return [];
|
||||
|
||||
return [
|
||||
buildTriggerSetupState(
|
||||
node,
|
||||
credentialTypes,
|
||||
credentialTypeStates.value,
|
||||
hasTriggerExecutedSuccessfully(node.name),
|
||||
),
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-node setup states — handles both parameter-only nodes and credential+parameter nodes.
|
||||
*
|
||||
* Parameter-only entries: nodes with parameter issues but NO credentials.
|
||||
* Credential entries: nodes with credentials that have parameter issues (split per node).
|
||||
* Only the first node with each credential type shows the credential picker.
|
||||
* Credential entries: nodes with credentials whose type is in `perNodeCredTypes`.
|
||||
* Only the first node per credential type shows the credential picker.
|
||||
|
||||
*/
|
||||
const nodeStates = computed<NodeSetupState[]>(() => {
|
||||
const result: NodeSetupState[] = [];
|
||||
@@ -447,33 +536,21 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
if (entry.credentialTypes.length > 0) continue;
|
||||
const { node, parameterIssues, isTrigger } = entry;
|
||||
|
||||
result.push({
|
||||
const state: NodeSetupState = {
|
||||
node,
|
||||
parameterIssues,
|
||||
templateParameterNames: templateParametersByNode.value.get(node.name),
|
||||
additionalParameterNames: templateParametersByNode.value.get(node.name),
|
||||
isTrigger,
|
||||
isComplete: Object.keys(parameterIssues).length === 0 && !hasUnfilledTemplateParams(node),
|
||||
});
|
||||
isComplete: false,
|
||||
};
|
||||
state.isComplete = isNodeSetupComplete(state, buildCompletionContext());
|
||||
result.push(state);
|
||||
}
|
||||
|
||||
// --- Credential+parameter nodes ---
|
||||
// Identify which credential types have ANY nodes with parameter issues,
|
||||
// template parameters, or had them in the past
|
||||
const credentialTypesWithParameters = new Set<string>();
|
||||
for (const { credentialTypes, parameterIssues, node } of nodesRequiringSetup.value) {
|
||||
if (Object.keys(parameterIssues).length > 0 || nodeHasTemplateParams(node.name)) {
|
||||
for (const credType of credentialTypes) {
|
||||
credentialTypesWithParameters.add(credType);
|
||||
seenCredentialTypesWithParameters.add(credType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build two maps:
|
||||
// 1. All nodes using each credential type (for "Used in X nodes" hint)
|
||||
// 2. Nodes with parameters for each credential type (for actual cards)
|
||||
// Build maps: all nodes per cred type + nodes with params per cred type
|
||||
const credTypeToAllNodes = new Map<string, INodeUi[]>();
|
||||
const credTypeToNodesWithParams = new Map<
|
||||
const credTypeToEntries = new Map<
|
||||
string,
|
||||
Array<{
|
||||
node: INodeUi;
|
||||
@@ -488,15 +565,10 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
if (credentialTypes.length === 0) continue;
|
||||
|
||||
for (const credType of credentialTypes) {
|
||||
if (
|
||||
!credentialTypesWithParameters.has(credType) &&
|
||||
!seenCredentialTypesWithParameters.has(credType)
|
||||
)
|
||||
continue;
|
||||
if (!perNodeCredTypes.value.has(credType)) continue;
|
||||
|
||||
if (!credTypeToAllNodes.has(credType)) credTypeToAllNodes.set(credType, []);
|
||||
|
||||
if (!credTypeToAllNodes.has(credType)) {
|
||||
credTypeToAllNodes.set(credType, []);
|
||||
}
|
||||
if (!credTypeToAllNodes.get(credType)!.some((n) => n.id === node.id)) {
|
||||
credTypeToAllNodes.get(credType)!.push(node);
|
||||
}
|
||||
@@ -504,20 +576,18 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
const combinationKey = `${credType}:${node.id}`;
|
||||
const hasParameters = Object.keys(parameterIssues).length > 0;
|
||||
const hasTemplateParams = nodeHasTemplateParams(node.name);
|
||||
const alreadySeen = seenNodeCredentials.has(combinationKey);
|
||||
const alreadySeen = stickyNodeCredentials.value.has(combinationKey);
|
||||
|
||||
if (hasParameters || hasTemplateParams || alreadySeen) {
|
||||
if (!credTypeToNodesWithParams.has(credType)) {
|
||||
credTypeToNodesWithParams.set(credType, []);
|
||||
}
|
||||
credTypeToNodesWithParams.get(credType)!.push(entry);
|
||||
if (!credTypeToEntries.has(credType)) credTypeToEntries.set(credType, []);
|
||||
credTypeToEntries.get(credType)!.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seenCombinations = new Set<string>();
|
||||
|
||||
for (const [credType, entries] of credTypeToNodesWithParams) {
|
||||
for (const [credType, entries] of credTypeToEntries) {
|
||||
let isFirstNode = true;
|
||||
const allNodesUsingCredential = credTypeToAllNodes.get(credType) ?? [];
|
||||
|
||||
@@ -528,8 +598,6 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
if (seenCombinations.has(combinationKey)) continue;
|
||||
seenCombinations.add(combinationKey);
|
||||
|
||||
seenNodeCredentials.add(combinationKey);
|
||||
|
||||
const credValue = node.credentials?.[credType];
|
||||
const selectedCredentialId =
|
||||
typeof credValue === 'string' ? undefined : (credValue?.id ?? undefined);
|
||||
@@ -538,45 +606,27 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
const issues = credentialIssues[credType];
|
||||
const issueMessages = [issues ?? []].flat();
|
||||
|
||||
const showCredentialPicker = isFirstNode;
|
||||
|
||||
const credentialComplete = !!selectedCredentialId && issueMessages.length === 0;
|
||||
const testPassed =
|
||||
!selectedCredentialId ||
|
||||
!isCredentialTypeTestable(credType) ||
|
||||
credentialsStore.isCredentialTestedOk(selectedCredentialId);
|
||||
|
||||
const isTriggerNodeType = (nodeType: string) => nodeTypesStore.isTriggerNode(nodeType);
|
||||
const triggerComplete =
|
||||
!isTriggerNodeType(node.type) ||
|
||||
node.name !== firstTriggerName.value ||
|
||||
hasTriggerExecutedSuccessfully(node.name);
|
||||
|
||||
const isAutoApplied =
|
||||
!!selectedCredentialId && autoAppliedCredentialIds.value.has(selectedCredentialId);
|
||||
|
||||
const parametersComplete =
|
||||
Object.keys(parameterIssues).length === 0 && !hasUnfilledTemplateParams(node);
|
||||
|
||||
const isComplete =
|
||||
triggerComplete && !isAutoApplied
|
||||
? parametersComplete
|
||||
: credentialComplete && testPassed && parametersComplete && triggerComplete;
|
||||
|
||||
result.push({
|
||||
const state: NodeSetupState = {
|
||||
node,
|
||||
credentialType: credType,
|
||||
credentialDisplayName: getCredentialDisplayName(credType),
|
||||
selectedCredentialId,
|
||||
issues: issueMessages,
|
||||
parameterIssues,
|
||||
templateParameterNames: templateParametersByNode.value.get(node.name),
|
||||
additionalParameterNames: templateParametersByNode.value.get(node.name),
|
||||
isTrigger,
|
||||
showCredentialPicker,
|
||||
isComplete,
|
||||
showCredentialPicker: isFirstNode,
|
||||
isComplete: false,
|
||||
allNodesUsingCredential,
|
||||
isAutoApplied,
|
||||
});
|
||||
};
|
||||
|
||||
state.isComplete = isNodeSetupComplete(state, buildCompletionContext(credType));
|
||||
|
||||
result.push(state);
|
||||
|
||||
isFirstNode = false;
|
||||
}
|
||||
@@ -585,6 +635,19 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
return result;
|
||||
});
|
||||
|
||||
// Persist node-credential combinations once shown
|
||||
watch(
|
||||
nodeStates,
|
||||
(states) => {
|
||||
for (const state of states) {
|
||||
if (state.credentialType) {
|
||||
stickyNodeCredentials.value.add(`${state.credentialType}:${state.node.id}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* Ordered list of all setup cards, sorted by the position of each card's
|
||||
* primary node in the execution order.
|
||||
@@ -835,6 +898,8 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
* Runs once when nodes become available so checkmarks reflect actual validity.
|
||||
* Deduplicates by credential ID so shared credentials are only tested once.
|
||||
*/
|
||||
const isInitialCredentialTestingDone = ref(false);
|
||||
|
||||
let initialSetupDone = false;
|
||||
watch(
|
||||
nodesRequiringSetup,
|
||||
@@ -860,8 +925,17 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const testPromises: Array<Promise<void>> = [];
|
||||
for (const [id, { name, type }] of credentialsToTest) {
|
||||
void testCredentialInBackground(id, name, type);
|
||||
testPromises.push(testCredentialInBackground(id, name, type));
|
||||
}
|
||||
|
||||
if (testPromises.length === 0) {
|
||||
isInitialCredentialTestingDone.value = true;
|
||||
} else {
|
||||
void Promise.allSettled(testPromises).then(() => {
|
||||
isInitialCredentialTestingDone.value = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -895,6 +969,8 @@ export const useWorkflowSetupState = (nodes?: Ref<INodeUi[]>) => {
|
||||
totalCredentialsMissing,
|
||||
totalCardsRequiringSetup,
|
||||
isAllComplete,
|
||||
isInitialCredentialTestingDone,
|
||||
|
||||
nodesWithMissingParameters,
|
||||
autoAppliedCredentialIds,
|
||||
setCredential,
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface NodeSetupState {
|
||||
|
||||
/** Parameter names from the upstream template that should always be shown
|
||||
* (resource locators and missing required parameters in the template). */
|
||||
templateParameterNames?: string[];
|
||||
additionalParameterNames?: string[];
|
||||
|
||||
// Credential fields — present when the node requires credentials
|
||||
credentialType?: string;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { createTestNode } from '@/__tests__/mocks';
|
||||
import { createTestNode, createTestNodeProperties } from '@/__tests__/mocks';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
getNodeCredentialTypes,
|
||||
getNodeParametersIssues,
|
||||
groupCredentialsByType,
|
||||
isCredentialCardComplete,
|
||||
buildTriggerSetupState,
|
||||
type CompletionContext,
|
||||
} from '@/features/setupPanel/setupPanel.utils';
|
||||
import type { CredentialTypeSetupState } from '@/features/setupPanel/setupPanel.types';
|
||||
|
||||
@@ -45,7 +48,22 @@ describe('setupPanel.utils', () => {
|
||||
expect(result).toEqual(['openAiApi', 'slackApi']);
|
||||
});
|
||||
|
||||
it('should include credential types from node issues', () => {
|
||||
it('should include credential types from node issues when displayable', () => {
|
||||
const node = createNode({
|
||||
issues: {
|
||||
credentials: {
|
||||
httpHeaderAuth: ['Credentials not set'],
|
||||
},
|
||||
},
|
||||
});
|
||||
mockGetNodeTypeDisplayableCredentials.mockReturnValue([{ name: 'httpHeaderAuth' }]);
|
||||
|
||||
const result = getNodeCredentialTypes(mockNodeTypeProvider, node);
|
||||
|
||||
expect(result).toContain('httpHeaderAuth');
|
||||
});
|
||||
|
||||
it('should include credential types from node issues even when not in displayable list', () => {
|
||||
const node = createNode({
|
||||
issues: {
|
||||
credentials: {
|
||||
@@ -59,7 +77,20 @@ describe('setupPanel.utils', () => {
|
||||
expect(result).toContain('httpHeaderAuth');
|
||||
});
|
||||
|
||||
it('should include credential types from assigned credentials', () => {
|
||||
it('should include credential types from assigned credentials when displayable', () => {
|
||||
const node = createNode({
|
||||
credentials: {
|
||||
slackApi: { id: 'cred-1', name: 'My Slack' },
|
||||
},
|
||||
});
|
||||
mockGetNodeTypeDisplayableCredentials.mockReturnValue([{ name: 'slackApi' }]);
|
||||
|
||||
const result = getNodeCredentialTypes(mockNodeTypeProvider, node);
|
||||
|
||||
expect(result).toContain('slackApi');
|
||||
});
|
||||
|
||||
it('should include credential types from assigned credentials even when not in displayable list', () => {
|
||||
const node = createNode({
|
||||
credentials: {
|
||||
slackApi: { id: 'cred-1', name: 'My Slack' },
|
||||
@@ -550,7 +581,24 @@ describe('setupPanel.utils', () => {
|
||||
});
|
||||
|
||||
describe('isCredentialCardComplete', () => {
|
||||
const isTrigger = (type: string) => type.includes('Trigger');
|
||||
const isTriggerNode = (type: string) => type.includes('Trigger');
|
||||
const noUnfilledParams = () => false;
|
||||
|
||||
function makeCtx(
|
||||
overrides: {
|
||||
hasTriggerExecuted?: (name: string) => boolean;
|
||||
isCredentialTestedOk?: (id: string) => boolean;
|
||||
firstTriggerName?: string | null;
|
||||
} = {},
|
||||
): CompletionContext {
|
||||
return {
|
||||
firstTriggerName: overrides.firstTriggerName ?? null,
|
||||
hasTriggerExecuted: overrides.hasTriggerExecuted ?? (() => false),
|
||||
isTriggerNode,
|
||||
isCredentialTestedOk: overrides.isCredentialTestedOk,
|
||||
hasUnfilledTemplateParams: noUnfilledParams,
|
||||
};
|
||||
}
|
||||
|
||||
it('should return true when credential is set, no issues, and no triggers', () => {
|
||||
const slackNode = createNode({ name: 'SlackNode', type: 'n8n-nodes-base.slack' });
|
||||
@@ -563,7 +611,7 @@ describe('setupPanel.utils', () => {
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
expect(isCredentialCardComplete(state, () => false, isTrigger)).toBe(true);
|
||||
expect(isCredentialCardComplete(state, makeCtx())).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when credential is missing', () => {
|
||||
@@ -577,7 +625,7 @@ describe('setupPanel.utils', () => {
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
expect(isCredentialCardComplete(state, () => true, isTrigger)).toBe(false);
|
||||
expect(isCredentialCardComplete(state, makeCtx())).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when there are issues', () => {
|
||||
@@ -591,7 +639,7 @@ describe('setupPanel.utils', () => {
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
expect(isCredentialCardComplete(state, () => true, isTrigger)).toBe(false);
|
||||
expect(isCredentialCardComplete(state, makeCtx())).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when trigger has not executed', () => {
|
||||
@@ -605,7 +653,15 @@ describe('setupPanel.utils', () => {
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
expect(isCredentialCardComplete(state, () => false, isTrigger)).toBe(false);
|
||||
expect(
|
||||
isCredentialCardComplete(
|
||||
state,
|
||||
makeCtx({
|
||||
hasTriggerExecuted: () => false,
|
||||
firstTriggerName: 'SlackTrigger',
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when credential is set and all triggers have executed', () => {
|
||||
@@ -619,7 +675,15 @@ describe('setupPanel.utils', () => {
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
expect(isCredentialCardComplete(state, () => true, isTrigger)).toBe(true);
|
||||
expect(
|
||||
isCredentialCardComplete(
|
||||
state,
|
||||
makeCtx({
|
||||
hasTriggerExecuted: () => true,
|
||||
firstTriggerName: 'SlackTrigger',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when single embedded trigger has executed', () => {
|
||||
@@ -633,7 +697,15 @@ describe('setupPanel.utils', () => {
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
expect(isCredentialCardComplete(state, () => true, isTrigger)).toBe(true);
|
||||
expect(
|
||||
isCredentialCardComplete(
|
||||
state,
|
||||
makeCtx({
|
||||
hasTriggerExecuted: () => true,
|
||||
firstTriggerName: 'Trigger1',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when credential test has not passed', () => {
|
||||
@@ -650,9 +722,9 @@ describe('setupPanel.utils', () => {
|
||||
expect(
|
||||
isCredentialCardComplete(
|
||||
state,
|
||||
() => false,
|
||||
isTrigger,
|
||||
() => false,
|
||||
makeCtx({
|
||||
isCredentialTestedOk: () => false,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -671,14 +743,14 @@ describe('setupPanel.utils', () => {
|
||||
expect(
|
||||
isCredentialCardComplete(
|
||||
state,
|
||||
() => false,
|
||||
isTrigger,
|
||||
() => true,
|
||||
makeCtx({
|
||||
isCredentialTestedOk: () => true,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should be backward-compatible when isCredentialTestedOk is not provided', () => {
|
||||
it('should complete when isCredentialTestedOk is not provided (non-testable type)', () => {
|
||||
const slackNode = createNode({ name: 'SlackNode', type: 'n8n-nodes-base.slack' });
|
||||
const state: CredentialTypeSetupState = {
|
||||
credentialType: 'slackApi',
|
||||
@@ -689,7 +761,104 @@ describe('setupPanel.utils', () => {
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
expect(isCredentialCardComplete(state, () => false, isTrigger)).toBe(true);
|
||||
expect(isCredentialCardComplete(state, makeCtx())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeParametersIssues', () => {
|
||||
it('should detect issues for the active variant when a parameter name has multiple displayOptions', () => {
|
||||
// Simulates node types like Google Drive Trigger that define multiple
|
||||
// properties with the same name (e.g. "event") for different triggerOn values.
|
||||
const nodeType = {
|
||||
properties: [
|
||||
createTestNodeProperties({
|
||||
displayName: 'Trigger On',
|
||||
name: 'triggerOn',
|
||||
type: 'options',
|
||||
required: true,
|
||||
default: '',
|
||||
options: [
|
||||
{ name: 'Specific File', value: 'specificFile' },
|
||||
{ name: 'Specific Folder', value: 'specificFolder' },
|
||||
{ name: 'Any File/Folder', value: 'anyFileFolder' },
|
||||
],
|
||||
}),
|
||||
createTestNodeProperties({
|
||||
displayName: 'Watch For',
|
||||
name: 'event',
|
||||
type: 'options',
|
||||
required: true,
|
||||
default: 'fileUpdated',
|
||||
displayOptions: { show: { triggerOn: ['specificFile'] } },
|
||||
}),
|
||||
createTestNodeProperties({
|
||||
displayName: 'Watch For',
|
||||
name: 'event',
|
||||
type: 'options',
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: { show: { triggerOn: ['specificFolder'] } },
|
||||
}),
|
||||
createTestNodeProperties({
|
||||
displayName: 'Watch For',
|
||||
name: 'event',
|
||||
type: 'options',
|
||||
required: true,
|
||||
default: 'fileCreated',
|
||||
displayOptions: { show: { triggerOn: ['anyFileFolder'] } },
|
||||
}),
|
||||
],
|
||||
} as unknown as INodeTypeDescription;
|
||||
|
||||
mockNodeTypeProvider.getNodeType.mockReturnValue(nodeType);
|
||||
|
||||
const node = createTestNode({
|
||||
type: 'n8n-nodes-base.googleDriveTrigger',
|
||||
parameters: {
|
||||
triggerOn: 'specificFolder',
|
||||
event: '',
|
||||
},
|
||||
});
|
||||
|
||||
const issues = getNodeParametersIssues(mockNodeTypeProvider, node);
|
||||
|
||||
expect(issues).toHaveProperty('event');
|
||||
});
|
||||
|
||||
it('should not include issues for parameter variants that are not displayed', () => {
|
||||
const nodeType = {
|
||||
properties: [
|
||||
createTestNodeProperties({
|
||||
displayName: 'Trigger On',
|
||||
name: 'triggerOn',
|
||||
type: 'options',
|
||||
required: true,
|
||||
default: 'specificFolder',
|
||||
}),
|
||||
createTestNodeProperties({
|
||||
displayName: 'Watch For',
|
||||
name: 'event',
|
||||
type: 'options',
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: { show: { triggerOn: ['specificFile'] } },
|
||||
}),
|
||||
],
|
||||
} as unknown as INodeTypeDescription;
|
||||
|
||||
mockNodeTypeProvider.getNodeType.mockReturnValue(nodeType);
|
||||
|
||||
const node = createTestNode({
|
||||
type: 'n8n-nodes-base.testTrigger',
|
||||
parameters: {
|
||||
triggerOn: 'specificFolder',
|
||||
event: '',
|
||||
},
|
||||
});
|
||||
|
||||
const issues = getNodeParametersIssues(mockNodeTypeProvider, node);
|
||||
|
||||
expect(issues).not.toHaveProperty('event');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ export const isHttpRequestNodeType = (nodeType: string): boolean =>
|
||||
|
||||
import type {
|
||||
CredentialTypeSetupState,
|
||||
NodeSetupState,
|
||||
TriggerSetupState,
|
||||
} from '@/features/setupPanel/setupPanel.types';
|
||||
import { type INode, type INodeParameters, NodeHelpers } from 'n8n-workflow';
|
||||
import { type INode, type INodeParameters, type INodeProperties, NodeHelpers } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Collects all credential types that a node requires from three sources:
|
||||
@@ -72,25 +73,42 @@ export function getNodeParametersIssues(nodeTypesStore: NodeTypeProvider, node:
|
||||
// Nested issues (e.g. a missing field inside a fixedCollection entry) use child
|
||||
// property names as keys which don't match top-level properties and can't be
|
||||
// configured in the setup card.
|
||||
const topLevelProps = new Map(nodeType.properties.map((p) => [p.name, p]));
|
||||
// Some node types define duplicate parameter names with different displayOptions
|
||||
// (e.g. "event" shown for different triggerOn values). We group all variants per
|
||||
// name so we can check whether ANY variant is currently displayed.
|
||||
const topLevelPropsByName = new Map<string, INodeProperties[]>();
|
||||
for (const prop of nodeType.properties) {
|
||||
const existing = topLevelPropsByName.get(prop.name);
|
||||
if (existing) {
|
||||
existing.push(prop);
|
||||
} else {
|
||||
topLevelPropsByName.set(prop.name, [prop]);
|
||||
}
|
||||
}
|
||||
const filteredIssues: Record<string, string[]> = {};
|
||||
for (const [key, value] of Object.entries(allIssues)) {
|
||||
const prop = topLevelProps.get(key);
|
||||
if (!prop) continue;
|
||||
const props = topLevelPropsByName.get(key);
|
||||
if (!props) continue;
|
||||
|
||||
// Skip hidden parameters — they are never shown to the user
|
||||
if (prop.type === 'hidden') continue;
|
||||
// Check if any variant of this parameter is visible
|
||||
const isDisplayed = props.some((prop) => {
|
||||
// Skip hidden parameters — they are never shown to the user
|
||||
if (prop.type === 'hidden') return false;
|
||||
|
||||
// Skip parameters whose displayOptions evaluate to hidden.
|
||||
// NodeHelpers.getParameterIssues already checks this internally, but it
|
||||
// treats expression values in controlling parameters as "always show".
|
||||
// This explicit check ensures consistency with the NDV's display logic.
|
||||
if (
|
||||
prop.displayOptions &&
|
||||
!NodeHelpers.displayParameter(paramsWithDefaults, prop, nodeWithDefaults, nodeType)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Skip parameters whose displayOptions evaluate to hidden.
|
||||
// NodeHelpers.getParameterIssues already checks this internally, but it
|
||||
// treats expression values in controlling parameters as "always show".
|
||||
// This explicit check ensures consistency with the NDV's display logic.
|
||||
if (
|
||||
prop.displayOptions &&
|
||||
!NodeHelpers.displayParameter(paramsWithDefaults, prop, nodeWithDefaults, nodeType)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
if (!isDisplayed) continue;
|
||||
|
||||
filteredIssues[key] = value;
|
||||
}
|
||||
@@ -184,34 +202,83 @@ export function groupCredentialsByType(
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
/** Callbacks needed by completion checks */
|
||||
export interface CompletionContext {
|
||||
firstTriggerName: string | null;
|
||||
hasTriggerExecuted: (nodeName: string) => boolean;
|
||||
isTriggerNode: (nodeType: string) => boolean;
|
||||
isCredentialTestedOk?: (credentialId: string) => boolean;
|
||||
hasUnfilledTemplateParams: (node: INodeUi) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a credential card is fully complete.
|
||||
* For cards with embedded triggers, complete = credential set + no issues + all triggers executed.
|
||||
* When isCredentialTestedOk is provided, also checks that the credential has passed testing.
|
||||
* Single source of truth for whether a per-node setup card is complete.
|
||||
* Handles credential, parameter, and trigger checks uniformly.
|
||||
*/
|
||||
export function isNodeSetupComplete(
|
||||
state: Pick<
|
||||
NodeSetupState,
|
||||
'credentialType' | 'selectedCredentialId' | 'issues' | 'parameterIssues' | 'isTrigger' | 'node'
|
||||
>,
|
||||
ctx: CompletionContext,
|
||||
): boolean {
|
||||
// Credential check
|
||||
if (state.credentialType) {
|
||||
if (!state.selectedCredentialId || (state.issues?.length ?? 0) > 0) return false;
|
||||
if (
|
||||
ctx.isCredentialTestedOk &&
|
||||
state.selectedCredentialId &&
|
||||
!ctx.isCredentialTestedOk(state.selectedCredentialId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Parameter check
|
||||
if (Object.keys(state.parameterIssues).length > 0 || ctx.hasUnfilledTemplateParams(state.node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Trigger check: only the first trigger requires execution
|
||||
if (
|
||||
state.isTrigger &&
|
||||
state.node.name === ctx.firstTriggerName &&
|
||||
!ctx.hasTriggerExecuted(state.node.name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a grouped credential card is fully complete.
|
||||
* For cards with embedded triggers, complete = credential set + no issues + test ok + all first-triggers executed.
|
||||
*/
|
||||
export function isCredentialCardComplete(
|
||||
credState: CredentialTypeSetupState,
|
||||
hasTriggerExecuted: (nodeName: string) => boolean,
|
||||
isTriggerNode: (nodeType: string) => boolean,
|
||||
isCredentialTestedOk?: (credentialId: string) => boolean,
|
||||
ctx: CompletionContext,
|
||||
): boolean {
|
||||
const credentialComplete = !!credState.selectedCredentialId && credState.issues.length === 0;
|
||||
if (!credentialComplete) return false;
|
||||
|
||||
if (
|
||||
isCredentialTestedOk &&
|
||||
ctx.isCredentialTestedOk &&
|
||||
credState.selectedCredentialId &&
|
||||
!isCredentialTestedOk(credState.selectedCredentialId)
|
||||
!ctx.isCredentialTestedOk(credState.selectedCredentialId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const triggerNodes = credState.nodes.filter((node) => isTriggerNode(node.type));
|
||||
return triggerNodes.every((node) => hasTriggerExecuted(node.name));
|
||||
// Only the first trigger in the group needs to have executed
|
||||
const triggerNodes = credState.nodes.filter(
|
||||
(node) => ctx.isTriggerNode(node.type) && node.name === ctx.firstTriggerName,
|
||||
);
|
||||
return triggerNodes.every((node) => ctx.hasTriggerExecuted(node.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the setup state for a trigger card.
|
||||
* Builds the setup state for a standalone trigger card.
|
||||
* Complete when: trigger has been executed AND all its credential types are satisfied.
|
||||
*/
|
||||
export function buildTriggerSetupState(
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { createBuilderStreamingResponse } from '../config/ai-builder-wizard-fixtures';
|
||||
import type { n8nPage } from '../pages/n8nPage';
|
||||
|
||||
/**
|
||||
* Multi-step flows for the AI Builder Setup Wizard.
|
||||
* Handles stream mocking, workflow generation, autosave, and follow-up messages.
|
||||
*/
|
||||
export class BuilderWizardComposer {
|
||||
constructor(private readonly n8n: n8nPage) {}
|
||||
|
||||
/**
|
||||
* Intercept the builder streaming endpoint and respond with mock workflow data.
|
||||
*/
|
||||
async mockBuilderStream(responseBody?: string) {
|
||||
const body = responseBody ?? createBuilderStreamingResponse();
|
||||
await this.n8n.page.route('**/rest/ai/build', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json-lines',
|
||||
body,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the builder stream mock with a new response (for follow-up messages).
|
||||
*/
|
||||
async remockBuilderStream(responseBody: string) {
|
||||
await this.n8n.page.unroute('**/rest/ai/build');
|
||||
await this.mockBuilderStream(responseBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the builder chat and send a prompt to trigger workflow generation.
|
||||
*/
|
||||
async triggerWorkflowGeneration() {
|
||||
await this.n8n.aiBuilder.getCanvasBuildWithAIButton().click();
|
||||
await expect(this.n8n.aiAssistant.getAskAssistantChat()).toBeVisible();
|
||||
await this.n8n.aiAssistant.sendMessage('Create a Slack notification workflow');
|
||||
await this.n8n.aiAssistant.waitForStreamingComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock workflow autosave (PATCH) so it completes instantly with a checksum.
|
||||
* with builder-generated nodes reaches the backend.
|
||||
*/
|
||||
async mockAutosave() {
|
||||
await this.n8n.page.route('**/rest/workflows/*', async (route) => {
|
||||
if (route.request().method() === 'PATCH') {
|
||||
const body = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
...body,
|
||||
checksum: nanoid(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a follow-up message to the builder after executing a workflow step.
|
||||
* Handles autosave timing and focus management that can interfere after execution.
|
||||
*/
|
||||
async sendFollowUpMessage(message: string) {
|
||||
// Close any dialogs that may have opened (e.g., rename dialog from canvas focus)
|
||||
await this.n8n.page.keyboard.press('Escape');
|
||||
|
||||
const chatInput = this.n8n.aiAssistant.getChatInput();
|
||||
await expect(chatInput).toBeEnabled({ timeout: 5000 });
|
||||
|
||||
await chatInput.click();
|
||||
await chatInput.fill(message);
|
||||
await expect(this.n8n.aiAssistant.getSendMessageButton()).toBeEnabled({ timeout: 5000 });
|
||||
await this.n8n.aiAssistant.getSendMessageButton().click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the card showing the given node name.
|
||||
* Clicks next until the card title matches — avoids brittle position-based navigation.
|
||||
*/
|
||||
async navigateToCard(nodeName: string) {
|
||||
const wizard = this.n8n.aiBuilder.wizard;
|
||||
const targetTitle = wizard.getCardTitle(nodeName);
|
||||
|
||||
// Already on the right card?
|
||||
if (await targetTitle.isVisible().catch(() => false)) return;
|
||||
|
||||
// Click next until we find it (max 10 clicks to avoid infinite loop)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const nextButton = wizard.getNextButton();
|
||||
if (!(await nextButton.isVisible().catch(() => false))) break;
|
||||
await nextButton.click();
|
||||
if (await targetTitle.isVisible().catch(() => false)) return;
|
||||
}
|
||||
|
||||
throw new Error(`Could not navigate to card "${nodeName}"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import type { TestRequirements } from '../Types';
|
||||
|
||||
// #region Mock Builder Responses
|
||||
|
||||
const STREAM_SEPARATOR = '⧉⇋⇋➽⌑⧉§§\n';
|
||||
|
||||
/**
|
||||
* Telegram node definition reused across multi-node fixtures.
|
||||
*/
|
||||
const telegramNode = {
|
||||
id: 'telegram-1',
|
||||
name: 'Telegram',
|
||||
type: 'n8n-nodes-base.telegram',
|
||||
typeVersion: 1.2,
|
||||
position: [440, 0],
|
||||
parameters: {
|
||||
resource: 'message',
|
||||
operation: 'sendMessage',
|
||||
chatId: '123456789',
|
||||
text: 'Hello from n8n!',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Schedule Trigger node definition reused across multi-node fixtures.
|
||||
*/
|
||||
const scheduleTriggerNode = {
|
||||
id: 'schedule-trigger-1',
|
||||
name: 'Schedule Trigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
typeVersion: 1.2,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
rule: { interval: [{ field: 'hours', hoursInterval: 1 }] },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Workflow with a Schedule Trigger (no credentials) and a Slack node (requires slackApi credential).
|
||||
* The Slack node uses concrete parameter values so that selecting a credential alone completes the card.
|
||||
*/
|
||||
const wizardWorkflowNodes = {
|
||||
nodes: [
|
||||
scheduleTriggerNode,
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2.2,
|
||||
position: [220, 0],
|
||||
parameters: {
|
||||
resource: 'message',
|
||||
operation: 'send',
|
||||
channelId: {
|
||||
__rl: true,
|
||||
mode: 'id',
|
||||
value: 'C01234567',
|
||||
},
|
||||
messageType: 'text',
|
||||
text: 'Hello from n8n!',
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Schedule Trigger': {
|
||||
main: [[{ node: 'Slack', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Builder streaming response that creates the wizard workflow.
|
||||
* Uses the json-lines format with STREAM_SEPARATOR.
|
||||
*/
|
||||
export function createBuilderStreamingResponse(
|
||||
workflowData: Record<string, unknown> = wizardWorkflowNodes,
|
||||
): string {
|
||||
const chunk = {
|
||||
sessionId: 'test-wizard-session',
|
||||
messages: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
text: "I've created your workflow with a Schedule Trigger and Slack integration.",
|
||||
},
|
||||
{
|
||||
type: 'workflow-updated',
|
||||
role: 'assistant',
|
||||
codeSnippet: JSON.stringify(workflowData),
|
||||
},
|
||||
],
|
||||
};
|
||||
return JSON.stringify(chunk) + STREAM_SEPARATOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder response with a placeholder value in the Slack node + a Telegram node.
|
||||
* Produces 2 visible cards after trigger-only filter: Slack (with placeholder) + Telegram.
|
||||
* Navigating to the Slack card triggers lazy placeholder clearing.
|
||||
*
|
||||
* Uses operation: 'post' (the real Slack V2 operation for sending messages) so that the
|
||||
* `text` parameter's displayOptions match and the parameter input is rendered in the card.
|
||||
*/
|
||||
export function createBuilderResponseWithPlaceholderAndTelegram(): string {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
wizardWorkflowNodes.nodes[0], // Schedule Trigger
|
||||
{ ...telegramNode, position: [220, 0] as [number, number] },
|
||||
{
|
||||
...wizardWorkflowNodes.nodes[1], // Slack (after Telegram in execution order)
|
||||
position: [440, 0] as [number, number],
|
||||
parameters: {
|
||||
resource: 'message',
|
||||
operation: 'post',
|
||||
channelId: { __rl: true, mode: 'id', value: 'C01234567' },
|
||||
messageType: 'text',
|
||||
text: '<__PLACEHOLDER_VALUE__notification message__>',
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Schedule Trigger': {
|
||||
main: [[{ node: 'Telegram', type: 'main', index: 0 }]],
|
||||
},
|
||||
Telegram: {
|
||||
main: [[{ node: 'Slack', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
return createBuilderStreamingResponse(workflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3-node workflow: Schedule Trigger → Slack → Telegram.
|
||||
* After trigger filter: 2 visible cards (Slack + Telegram).
|
||||
*/
|
||||
export function createBuilderResponseTwoCards(): string {
|
||||
const workflow = {
|
||||
nodes: [...wizardWorkflowNodes.nodes, telegramNode],
|
||||
connections: {
|
||||
'Schedule Trigger': {
|
||||
main: [[{ node: 'Slack', type: 'main', index: 0 }]],
|
||||
},
|
||||
Slack: {
|
||||
main: [[{ node: 'Telegram', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
return createBuilderStreamingResponse(workflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow-up response that INSERTS a Telegram node between the trigger and Slack.
|
||||
* Execution order becomes: Schedule Trigger → Telegram → Slack.
|
||||
* Used to test that the wizard starts from the new (incomplete) node rather than
|
||||
* skipping past it to after the last completed node.
|
||||
*/
|
||||
export function createBuilderFollowUpWithInsertedNode(): string {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
wizardWorkflowNodes.nodes[0], // Schedule Trigger
|
||||
{ ...telegramNode, position: [220, 0] as [number, number] }, // Telegram (inserted)
|
||||
{ ...wizardWorkflowNodes.nodes[1], position: [440, 0] as [number, number] }, // Slack (shifted)
|
||||
],
|
||||
connections: {
|
||||
'Schedule Trigger': {
|
||||
main: [[{ node: 'Telegram', type: 'main', index: 0 }]],
|
||||
},
|
||||
Telegram: {
|
||||
main: [[{ node: 'Slack', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
return createBuilderStreamingResponse(workflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-trigger workflow: Morning Schedule → Slack, plus a standalone Telegram Trigger.
|
||||
* After trigger filter: 2 visible cards (Slack + Telegram Listener).
|
||||
* The Morning Schedule trigger card is filtered out as trigger-only.
|
||||
* Only non-trigger nodes get an execute button; Telegram Listener (a trigger) does not.
|
||||
*/
|
||||
export function createBuilderResponseMultipleTriggers(): string {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
...scheduleTriggerNode,
|
||||
name: 'Morning Schedule',
|
||||
parameters: {
|
||||
rule: { interval: [{ field: 'hours', hoursInterval: 8 }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2.2,
|
||||
position: [220, 0],
|
||||
parameters: {
|
||||
resource: 'message',
|
||||
operation: 'send',
|
||||
channelId: { __rl: true, mode: 'id', value: 'C01234567' },
|
||||
messageType: 'text',
|
||||
text: 'Good morning!',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'telegram-trigger-1',
|
||||
name: 'Telegram Listener',
|
||||
type: 'n8n-nodes-base.telegramTrigger',
|
||||
typeVersion: 1.2,
|
||||
position: [0, 300],
|
||||
parameters: {
|
||||
updates: ['message'],
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Morning Schedule': {
|
||||
main: [[{ node: 'Slack', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
return createBuilderStreamingResponse(workflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow with two Slack nodes sharing the same credential type (slackApi).
|
||||
* Execution order: Schedule Trigger → Slack Alerts → Slack Reports.
|
||||
* After trigger filter: 1 card (slackApi credential, grouping both nodes).
|
||||
* The card should show "Used in 2 nodes" hint.
|
||||
*/
|
||||
export function createBuilderResponseSharedCredential(): string {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
scheduleTriggerNode,
|
||||
{
|
||||
id: 'slack-alerts',
|
||||
name: 'Slack Alerts',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2.2,
|
||||
position: [220, 0],
|
||||
parameters: {
|
||||
resource: 'message',
|
||||
operation: 'send',
|
||||
channelId: { __rl: true, mode: 'id', value: 'C-ALERTS' },
|
||||
messageType: 'text',
|
||||
text: 'Alert!',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'slack-reports',
|
||||
name: 'Slack Reports',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2.2,
|
||||
position: [440, 0],
|
||||
parameters: {
|
||||
resource: 'message',
|
||||
operation: 'send',
|
||||
channelId: { __rl: true, mode: 'id', value: 'C-REPORTS' },
|
||||
messageType: 'text',
|
||||
text: 'Daily report',
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Schedule Trigger': {
|
||||
main: [[{ node: 'Slack Alerts', type: 'main', index: 0 }]],
|
||||
},
|
||||
'Slack Alerts': {
|
||||
main: [[{ node: 'Slack Reports', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
return createBuilderStreamingResponse(workflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Branching workflow: Schedule Trigger → If → Slack (true) / Telegram (false).
|
||||
* After trigger filter: 2 cards (Slack credential + Telegram credential).
|
||||
* Tests that cards appear for nodes across different conditional branches.
|
||||
*/
|
||||
export function createBuilderResponseBranchingWorkflow(): string {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
scheduleTriggerNode,
|
||||
{
|
||||
id: 'if-1',
|
||||
name: 'Check Condition',
|
||||
type: 'n8n-nodes-base.if',
|
||||
typeVersion: 2,
|
||||
position: [220, 0],
|
||||
parameters: {
|
||||
conditions: {
|
||||
options: { caseSensitive: true, leftValue: '' },
|
||||
conditions: [
|
||||
{
|
||||
leftValue: '={{ $json.value }}',
|
||||
rightValue: 'yes',
|
||||
operator: { type: 'string', operation: 'equals' },
|
||||
},
|
||||
],
|
||||
combinator: 'and',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack Notification',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2.2,
|
||||
position: [440, -100],
|
||||
parameters: {
|
||||
resource: 'message',
|
||||
operation: 'send',
|
||||
channelId: { __rl: true, mode: 'id', value: 'C01234567' },
|
||||
messageType: 'text',
|
||||
text: 'Condition met!',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'telegram-1',
|
||||
name: 'Telegram Fallback',
|
||||
type: 'n8n-nodes-base.telegram',
|
||||
typeVersion: 1.2,
|
||||
position: [440, 100],
|
||||
parameters: {
|
||||
resource: 'message',
|
||||
operation: 'sendMessage',
|
||||
chatId: '123456789',
|
||||
text: 'Condition not met',
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Schedule Trigger': {
|
||||
main: [[{ node: 'Check Condition', type: 'main', index: 0 }]],
|
||||
},
|
||||
'Check Condition': {
|
||||
main: [
|
||||
[{ node: 'Slack Notification', type: 'main', index: 0 }],
|
||||
[{ node: 'Telegram Fallback', type: 'main', index: 0 }],
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
return createBuilderStreamingResponse(workflow);
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Test Requirements
|
||||
|
||||
export const builderWizardRequirements: TestRequirements = {
|
||||
config: {
|
||||
settings: {
|
||||
aiAssistant: { enabled: true, setup: true },
|
||||
aiBuilder: { enabled: true, setup: true },
|
||||
},
|
||||
features: {
|
||||
aiAssistant: true,
|
||||
aiBuilder: true,
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
N8N_EXPERIMENT_OVERRIDES: JSON.stringify({ '079_ai_builder_setup_wizard': 'variant' }),
|
||||
},
|
||||
};
|
||||
|
||||
// #endregion
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { BuilderSetupWizardPage } from './BuilderSetupWizardPage';
|
||||
|
||||
/**
|
||||
* Page object for AI Workflow Builder interactions
|
||||
*/
|
||||
export class AIBuilderPage {
|
||||
readonly page: Page;
|
||||
readonly wizard: BuilderSetupWizardPage;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.wizard = new BuilderSetupWizardPage(page);
|
||||
}
|
||||
|
||||
// #region Locators
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Page object for the AI Builder Setup Wizard card UI.
|
||||
* Encapsulates all wizard locators so specs don't use raw selectors.
|
||||
*/
|
||||
export class BuilderSetupWizardPage {
|
||||
readonly page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
// #region Locators
|
||||
|
||||
/** The outer wizard container (hidden once all cards complete + workflow executed) */
|
||||
getWizard(): Locator {
|
||||
return this.page.getByTestId('builder-setup-wizard');
|
||||
}
|
||||
|
||||
/** The currently visible setup card */
|
||||
getCard(): Locator {
|
||||
return this.page.getByTestId('builder-setup-card');
|
||||
}
|
||||
|
||||
/** Previous-step arrow inside the card footer */
|
||||
getPrevButton(): Locator {
|
||||
return this.page.getByTestId('builder-setup-card-prev');
|
||||
}
|
||||
|
||||
/** Next-step arrow inside the card footer */
|
||||
getNextButton(): Locator {
|
||||
return this.page.getByTestId('builder-setup-card-next');
|
||||
}
|
||||
|
||||
/** The trigger / node execute button inside the card */
|
||||
getExecuteStepButton(): Locator {
|
||||
return this.page.getByTestId('trigger-execute-button');
|
||||
}
|
||||
|
||||
/** Green check mark shown when a card is complete */
|
||||
getCompleteCheck(): Locator {
|
||||
return this.getCard().getByTestId('builder-setup-card-check');
|
||||
}
|
||||
|
||||
/** Credential label/picker section inside the card */
|
||||
getCredentialLabel(): Locator {
|
||||
return this.page.getByTestId('credentials-label');
|
||||
}
|
||||
|
||||
/** Credential dropdown select inside the card */
|
||||
getCredentialSelect(): Locator {
|
||||
return this.getCard().getByTestId('node-credentials-select');
|
||||
}
|
||||
|
||||
/** Parameter input for a specific parameter name */
|
||||
getParameterInput(paramName: string): Locator {
|
||||
return this.page.getByTestId(`parameter-input-${paramName}`);
|
||||
}
|
||||
|
||||
/** "Used in X nodes" hint shown on grouped credential cards */
|
||||
getNodesHint(): Locator {
|
||||
return this.page.getByTestId('builder-setup-card-nodes-hint');
|
||||
}
|
||||
|
||||
/** Step indicator text, e.g. "1 of 2" */
|
||||
getStepIndicator(current: number, total: number): Locator {
|
||||
return this.getCard().getByText(`${current} of ${total}`);
|
||||
}
|
||||
|
||||
/** Card title (node name) */
|
||||
getCardTitle(name: string): Locator {
|
||||
return this.getCard().getByText(name, { exact: true });
|
||||
}
|
||||
|
||||
// #endregion
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { WorkflowCredentialSetupModal } from './WorkflowCredentialSetupModal';
|
||||
import { WorkflowSettingsModal } from './WorkflowSettingsModal';
|
||||
import { WorkflowSharingModal } from './WorkflowSharingModal';
|
||||
import { WorkflowsPage } from './WorkflowsPage';
|
||||
import { BuilderWizardComposer } from '../composables/BuilderWizardComposer';
|
||||
import { CanvasComposer } from '../composables/CanvasComposer';
|
||||
import { CredentialsComposer } from '../composables/CredentialsComposer';
|
||||
import { DataTableComposer } from '../composables/DataTablesComposer';
|
||||
@@ -125,6 +126,7 @@ export class n8nPage {
|
||||
readonly deleteSecretsProviderModal: DeleteSecretsProviderModal;
|
||||
|
||||
// Composables
|
||||
readonly builderWizardComposer: BuilderWizardComposer;
|
||||
readonly workflowComposer: WorkflowComposer;
|
||||
readonly projectComposer: ProjectComposer;
|
||||
readonly canvasComposer: CanvasComposer;
|
||||
@@ -202,6 +204,7 @@ export class n8nPage {
|
||||
this.deleteSecretsProviderModal = new DeleteSecretsProviderModal(page);
|
||||
|
||||
// Composables
|
||||
this.builderWizardComposer = new BuilderWizardComposer(this);
|
||||
this.workflowComposer = new WorkflowComposer(this);
|
||||
this.projectComposer = new ProjectComposer(this);
|
||||
this.canvasComposer = new CanvasComposer(this);
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import {
|
||||
builderWizardRequirements,
|
||||
createBuilderStreamingResponse,
|
||||
createBuilderResponseWithPlaceholderAndTelegram,
|
||||
createBuilderFollowUpWithInsertedNode,
|
||||
createBuilderResponseMultipleTriggers,
|
||||
createBuilderResponseTwoCards,
|
||||
createBuilderResponseSharedCredential,
|
||||
createBuilderResponseBranchingWorkflow,
|
||||
} from '../../../config/ai-builder-wizard-fixtures';
|
||||
import { test, expect } from '../../../fixtures/base';
|
||||
|
||||
test.describe(
|
||||
'Builder Setup Wizard @auth:owner @ai',
|
||||
{
|
||||
annotation: [{ type: 'owner', description: 'AI' }],
|
||||
},
|
||||
() => {
|
||||
let projectId: string;
|
||||
|
||||
test.beforeEach(async ({ n8n, setupRequirements }) => {
|
||||
await setupRequirements(builderWizardRequirements);
|
||||
|
||||
await n8n.page.route('**/rest/ai/build/credits', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ data: { creditsQuota: 100, creditsClaimed: 0 } }),
|
||||
});
|
||||
});
|
||||
|
||||
await n8n.page.route('**/rest/ai/sessions', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ data: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await n8n.page.route('**/rest/credentials/test', async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ data: { status: 'OK', message: 'Tested successfully' } }),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
projectId = await n8n.start.fromNewProjectBlankCanvas();
|
||||
});
|
||||
|
||||
test('should disable per-card execute button when credentials are missing', async ({ n8n }) => {
|
||||
const { builderWizardComposer: bw } = n8n;
|
||||
const wiz = n8n.aiBuilder.wizard;
|
||||
|
||||
await bw.mockBuilderStream();
|
||||
await bw.triggerWorkflowGeneration();
|
||||
|
||||
await expect(wiz.getWizard()).toBeVisible();
|
||||
await expect(wiz.getCard()).toBeVisible();
|
||||
await expect(wiz.getExecuteStepButton()).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should allow stepping through cards in both directions', async ({ n8n }) => {
|
||||
const { builderWizardComposer: bw } = n8n;
|
||||
const wiz = n8n.aiBuilder.wizard;
|
||||
|
||||
// 3-node fixture → 2 visible cards after trigger filter (Slack + Telegram)
|
||||
await bw.mockBuilderStream(createBuilderResponseTwoCards());
|
||||
await bw.triggerWorkflowGeneration();
|
||||
|
||||
// Should start on Slack (card 1/2)
|
||||
await expect(wiz.getCardTitle('Slack')).toBeVisible();
|
||||
await expect(wiz.getStepIndicator(1, 2)).toBeVisible();
|
||||
|
||||
// Navigate forward → Telegram (card 2/2)
|
||||
await wiz.getNextButton().click();
|
||||
await expect(wiz.getCardTitle('Telegram')).toBeVisible();
|
||||
await expect(wiz.getStepIndicator(2, 2)).toBeVisible();
|
||||
|
||||
// Navigate back → Slack (card 1/2)
|
||||
await wiz.getPrevButton().click();
|
||||
await expect(wiz.getCardTitle('Slack')).toBeVisible();
|
||||
await expect(wiz.getStepIndicator(1, 2)).toBeVisible();
|
||||
});
|
||||
|
||||
// --- Follow-up behavior ---
|
||||
|
||||
test('should reset to first incomplete card after AI updates workflow', async ({ n8n }) => {
|
||||
const { builderWizardComposer: bw } = n8n;
|
||||
const wiz = n8n.aiBuilder.wizard;
|
||||
|
||||
// 2 visible cards: Slack (card 1) + Telegram (card 2)
|
||||
await bw.mockBuilderStream(createBuilderResponseTwoCards());
|
||||
await bw.mockAutosave();
|
||||
await bw.triggerWorkflowGeneration();
|
||||
|
||||
// Navigate to Telegram (card 2/2)
|
||||
await bw.navigateToCard('Telegram');
|
||||
await expect(wiz.getCardTitle('Telegram')).toBeVisible();
|
||||
await expect(wiz.getStepIndicator(2, 2)).toBeVisible();
|
||||
|
||||
// Send follow-up with the same structure (both cards still incomplete)
|
||||
await bw.remockBuilderStream(createBuilderResponseTwoCards());
|
||||
await bw.sendFollowUpMessage('Adjust the workflow');
|
||||
await n8n.aiAssistant.waitForStreamingComplete();
|
||||
|
||||
// Wizard should reset to Slack (first incomplete card), not stay on Telegram
|
||||
await expect(wiz.getCardTitle('Slack')).toBeVisible({ timeout: 5000 });
|
||||
await expect(wiz.getStepIndicator(1, 2)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should land on new incomplete card when follow-up inserts a node', async ({ n8n }) => {
|
||||
const { builderWizardComposer: bw } = n8n;
|
||||
const wiz = n8n.aiBuilder.wizard;
|
||||
|
||||
await bw.mockBuilderStream(createBuilderStreamingResponse());
|
||||
await bw.mockAutosave();
|
||||
await bw.triggerWorkflowGeneration();
|
||||
|
||||
// Follow-up inserts Telegram between trigger and Slack.
|
||||
// After filter: Telegram + Slack = 2 cards.
|
||||
await bw.remockBuilderStream(createBuilderFollowUpWithInsertedNode());
|
||||
await bw.sendFollowUpMessage('Add a Telegram notification before Slack');
|
||||
await n8n.aiAssistant.waitForStreamingComplete();
|
||||
|
||||
// Wizard should land on Telegram (first incomplete card in execution order)
|
||||
await expect(wiz.getCardTitle('Telegram')).toBeVisible({ timeout: 5000 });
|
||||
await expect(wiz.getStepIndicator(1, 2)).toBeVisible();
|
||||
});
|
||||
|
||||
// --- Multi-trigger ---
|
||||
|
||||
test('should only allow executing the first trigger in a multi-trigger workflow', async ({
|
||||
n8n,
|
||||
}) => {
|
||||
const { builderWizardComposer: bw } = n8n;
|
||||
const wiz = n8n.aiBuilder.wizard;
|
||||
|
||||
// Multi-trigger: Morning Schedule (trigger-only, filtered) + Slack + Telegram Listener
|
||||
// After filter: 2 visible cards (Slack + Telegram Listener)
|
||||
await bw.mockBuilderStream(createBuilderResponseMultipleTriggers());
|
||||
|
||||
await bw.triggerWorkflowGeneration();
|
||||
|
||||
// Card 1: Slack — has credential picker and execute button (non-trigger, executable)
|
||||
await expect(wiz.getCardTitle('Slack')).toBeVisible();
|
||||
await expect(wiz.getStepIndicator(1, 2)).toBeVisible();
|
||||
await expect(wiz.getCredentialLabel()).toBeVisible();
|
||||
await expect(wiz.getExecuteStepButton()).toBeVisible();
|
||||
|
||||
// Card 2: Telegram Listener (second trigger) — has credential picker but NO execute button
|
||||
await wiz.getNextButton().click();
|
||||
await expect(wiz.getCardTitle('Telegram Listener')).toBeVisible();
|
||||
await expect(wiz.getStepIndicator(2, 2)).toBeVisible();
|
||||
await expect(wiz.getCredentialLabel()).toBeVisible();
|
||||
await expect(wiz.getExecuteStepButton()).not.toBeVisible();
|
||||
});
|
||||
|
||||
// --- Credential grouping ---
|
||||
|
||||
test('should group nodes sharing the same credential type into one card', async ({ n8n }) => {
|
||||
const { builderWizardComposer: bw } = n8n;
|
||||
const wiz = n8n.aiBuilder.wizard;
|
||||
|
||||
// Two Slack nodes (Slack Alerts + Slack Reports) both need slackApi → one grouped card
|
||||
await bw.mockBuilderStream(createBuilderResponseSharedCredential());
|
||||
|
||||
await bw.triggerWorkflowGeneration();
|
||||
|
||||
// Only 1 card should exist (both Slack nodes grouped under slackApi)
|
||||
await expect(wiz.getCard()).toBeVisible();
|
||||
await expect(wiz.getCredentialLabel()).toBeVisible();
|
||||
|
||||
// "Used in 2 nodes" hint confirms grouping
|
||||
await expect(wiz.getNodesHint()).toBeVisible();
|
||||
|
||||
// No step indicator arrows (single card)
|
||||
await expect(wiz.getNextButton()).not.toBeVisible();
|
||||
await expect(wiz.getPrevButton()).not.toBeVisible();
|
||||
});
|
||||
|
||||
// --- Branching workflows ---
|
||||
|
||||
test('should show cards for nodes across conditional branches', async ({ n8n }) => {
|
||||
const { builderWizardComposer: bw } = n8n;
|
||||
const wiz = n8n.aiBuilder.wizard;
|
||||
|
||||
// If node with Slack (true branch) and Telegram (false branch)
|
||||
// After filter: 2 cards for the two credential-requiring nodes
|
||||
await bw.mockBuilderStream(createBuilderResponseBranchingWorkflow());
|
||||
|
||||
await bw.triggerWorkflowGeneration();
|
||||
|
||||
// Card 1: Slack Notification (true branch)
|
||||
await expect(wiz.getCardTitle('Slack Notification')).toBeVisible();
|
||||
await expect(wiz.getStepIndicator(1, 2)).toBeVisible();
|
||||
await expect(wiz.getCredentialLabel()).toBeVisible();
|
||||
|
||||
// Card 2: Telegram Fallback (false branch)
|
||||
await wiz.getNextButton().click();
|
||||
await expect(wiz.getCardTitle('Telegram Fallback')).toBeVisible();
|
||||
await expect(wiz.getStepIndicator(2, 2)).toBeVisible();
|
||||
await expect(wiz.getCredentialLabel()).toBeVisible();
|
||||
});
|
||||
|
||||
// --- Placeholder parameter handling ---
|
||||
|
||||
test('should show placeholder parameters and keep card incomplete until filled', async ({
|
||||
n8n,
|
||||
}) => {
|
||||
const { builderWizardComposer: bw } = n8n;
|
||||
const wiz = n8n.aiBuilder.wizard;
|
||||
|
||||
// 2-card fixture: Telegram (card 1) + Slack with placeholder (card 2).
|
||||
await bw.mockBuilderStream(createBuilderResponseWithPlaceholderAndTelegram());
|
||||
|
||||
await bw.triggerWorkflowGeneration();
|
||||
|
||||
// Navigate to Slack card
|
||||
await bw.navigateToCard('Slack');
|
||||
|
||||
// Parameter input should be visible with placeholder text for user to type over
|
||||
await expect(wiz.getParameterInput('text')).toBeVisible();
|
||||
|
||||
// Card should NOT be complete — placeholder value doesn't count as filled
|
||||
await expect(wiz.getCompleteCheck()).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should not complete a card with credential alone when parameters are required', async ({
|
||||
n8n,
|
||||
api,
|
||||
}) => {
|
||||
await api.credentials.createCredential({
|
||||
name: `Slack Test ${nanoid()}`,
|
||||
type: 'slackApi',
|
||||
data: { accessToken: 'xoxb-test-token' },
|
||||
projectId,
|
||||
});
|
||||
|
||||
const { builderWizardComposer: bw } = n8n;
|
||||
const wiz = n8n.aiBuilder.wizard;
|
||||
|
||||
// 2-card fixture with placeholder so navigating triggers clearing.
|
||||
// Execution order: Telegram (card 1) → Slack with placeholder (card 2).
|
||||
await bw.mockBuilderStream(createBuilderResponseWithPlaceholderAndTelegram());
|
||||
|
||||
await bw.triggerWorkflowGeneration();
|
||||
|
||||
// Navigate to Slack card (triggers placeholder clearing)
|
||||
await bw.navigateToCard('Slack');
|
||||
|
||||
// Credential is auto-applied, but the text parameter is empty (placeholder was cleared).
|
||||
// Card should NOT be complete — both credential AND parameters are required.
|
||||
await expect(wiz.getCompleteCheck()).not.toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user