diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 4b0cdd44c6a..f4e30a74025 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/app/constants/experiments.ts b/packages/frontend/editor-ui/src/app/constants/experiments.ts index 70ef62dcaaa..30e0da4dc6a 100644 --- a/packages/frontend/editor-ui/src/app/constants/experiments.ts +++ b/packages/frontend/editor-ui/src/app/constants/experiments.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/app/utils/workflowUtils.test.ts b/packages/frontend/editor-ui/src/app/utils/workflowUtils.test.ts index 59773996916..b60fca7ed02 100644 --- a/packages/frontend/editor-ui/src/app/utils/workflowUtils.test.ts +++ b/packages/frontend/editor-ui/src/app/utils/workflowUtils.test.ts @@ -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', () => { diff --git a/packages/frontend/editor-ui/src/app/utils/workflowUtils.ts b/packages/frontend/editor-ui/src/app/utils/workflowUtils.ts index 968e42ffcf9..bcb905c7774 100644 --- a/packages/frontend/editor-ui/src/app/utils/workflowUtils.ts +++ b/packages/frontend/editor-ui/src/app/utils/workflowUtils.ts @@ -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( const result: T[] = []; const visited = new Set(); - 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); } diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts b/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts index 93fd0fe3a39..aff42e55485 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts @@ -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()); + 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, diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.test.ts b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.test.ts index 922f920c7b2..6586993547f 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.test.ts @@ -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 () => { diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue index 495fc65e75f..07a0d156596 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue +++ b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue @@ -104,18 +104,8 @@ const chatInputRef = ref>(); const suggestionsInputRef = ref>(); 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; diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/BuilderSetupCard.test.ts b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/BuilderSetupCard.test.ts new file mode 100644 index 00000000000..e15123f04f3 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/BuilderSetupCard.test.ts @@ -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: '', + props: ['nodeType', 'size'], + }, +})); + +vi.mock('@/features/credentials/components/CredentialIcon.vue', () => ({ + default: { + template: '', + props: ['credentialTypeName', 'size'], + }, +})); + +vi.mock('@/features/credentials/components/NodeCredentials.vue', () => ({ + default: { + template: '
', + props: ['node', 'overrideCredType', 'skipAutoSelect', 'hideIssues'], + }, +})); + +vi.mock('@/features/ndv/parameters/components/ParameterInputList.vue', () => ({ + default: { + template: '
', + props: ['parameters', 'nodeValues', 'node'], + }, +})); + +vi.mock('@/features/setupPanel/components/TriggerExecuteButton.vue', () => ({ + default: { + template: '', + props: ['label', 'icon', 'disabled', 'loading', 'tooltipItems'], + }, +})); + +vi.mock('@/features/setupPanel/components/WebhookUrlPreview.vue', () => ({ + default: { + template: '
', + 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 { + return createTestNode({ + name: 'Test Node', + type: 'n8n-nodes-base.httpRequest', + ...overrides, + }) as INodeUi; +} + +function createState(overrides: Partial = {}): 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 = {}, + props: Record = {}, + ) { + 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(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/BuilderSetupCard.vue b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/BuilderSetupCard.vue new file mode 100644 index 00000000000..24e2caa7312 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/BuilderSetupCard.vue @@ -0,0 +1,443 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/BuilderSetupWizard.test.ts b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/BuilderSetupWizard.test.ts new file mode 100644 index 00000000000..eb0eafe0f3d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/BuilderSetupWizard.test.ts @@ -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 }>>([]); +const mockCurrentCard = ref<{ state: Record } | 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: '
', + 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>; + let builderStore: ReturnType>; + let pinia: ReturnType; + + 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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/BuilderSetupWizard.vue b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/BuilderSetupWizard.vue new file mode 100644 index 00000000000..9cf2578af74 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/BuilderSetupWizard.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/ExecuteMessage.vue b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/ExecuteMessage.vue index 85a1fb9aefc..4601b845d8c 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/ExecuteMessage.vue +++ b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/ExecuteMessage.vue @@ -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(); -// 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(); - -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(); 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[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(); -});