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:
Svetoslav Dekov
2026-03-20 10:18:25 +00:00
committed by GitHub
co-authored by Charlie Kolb Claude Opus 4.6
parent 6fcc86037d
commit 2ff73d5440
30 changed files with 3691 additions and 396 deletions
@@ -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,
@@ -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 () => {
@@ -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;
@@ -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();
});
});
});
@@ -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>
@@ -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();
});
});
@@ -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>
@@ -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"
@@ -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,
};
}
@@ -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);
});
});
@@ -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);
}
}
@@ -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) {
@@ -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');
@@ -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();
});
},
);