diff --git a/package.json b/package.json index a8056ef5420..f633b21fa5e 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "ics": "patches/ics.patch", "minifaker": "patches/minifaker.patch", "z-vue-scan": "patches/z-vue-scan.patch", + "@lezer/highlight": "patches/@lezer__highlight.patch", "v-code-diff": "patches/v-code-diff.patch" } } diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/Chat.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/Chat.node.ts new file mode 100644 index 00000000000..042211ab898 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/Chat.node.ts @@ -0,0 +1,273 @@ +/* eslint-disable n8n-nodes-base/node-dirname-against-convention */ +import type { BaseChatMemory } from 'langchain/memory'; +import { + CHAT_TRIGGER_NODE_TYPE, + CHAT_WAIT_USER_REPLY, + NodeConnectionTypes, + NodeOperationError, +} from 'n8n-workflow'; +import type { + IExecuteFunctions, + INodeExecutionData, + INodeTypeDescription, + INodeType, + INodeProperties, +} from 'n8n-workflow'; + +import { configureInputs, configureWaitTillDate } from './util'; + +const limitWaitTimeProperties: INodeProperties[] = [ + { + displayName: 'Limit Type', + name: 'limitType', + type: 'options', + default: 'afterTimeInterval', + description: + 'Sets the condition for the execution to resume. Can be a specified date or after some time.', + options: [ + { + name: 'After Time Interval', + description: 'Waits for a certain amount of time', + value: 'afterTimeInterval', + }, + { + name: 'At Specified Time', + description: 'Waits until the set date and time to continue', + value: 'atSpecifiedTime', + }, + ], + }, + { + displayName: 'Amount', + name: 'resumeAmount', + type: 'number', + displayOptions: { + show: { + limitType: ['afterTimeInterval'], + }, + }, + typeOptions: { + minValue: 0, + numberPrecision: 2, + }, + default: 1, + description: 'The time to wait', + }, + { + displayName: 'Unit', + name: 'resumeUnit', + type: 'options', + displayOptions: { + show: { + limitType: ['afterTimeInterval'], + }, + }, + options: [ + { + name: 'Minutes', + value: 'minutes', + }, + { + name: 'Hours', + value: 'hours', + }, + { + name: 'Days', + value: 'days', + }, + ], + default: 'hours', + description: 'Unit of the interval value', + }, + { + displayName: 'Max Date and Time', + name: 'maxDateAndTime', + type: 'dateTime', + displayOptions: { + show: { + limitType: ['atSpecifiedTime'], + }, + }, + default: '', + description: 'Continue execution after the specified date and time', + }, +]; + +const limitWaitTimeOption: INodeProperties = { + displayName: 'Limit Wait Time', + name: 'limitWaitTime', + type: 'fixedCollection', + description: + 'Whether to limit the time this node should wait for a user response before execution resumes', + default: { values: { limitType: 'afterTimeInterval', resumeAmount: 45, resumeUnit: 'minutes' } }, + options: [ + { + displayName: 'Values', + name: 'values', + values: limitWaitTimeProperties, + }, + ], + displayOptions: { + show: { + [`/${CHAT_WAIT_USER_REPLY}`]: [true], + }, + }, +}; + +export class Chat implements INodeType { + description: INodeTypeDescription = { + displayName: 'Respond to Chat', + name: 'chat', + icon: 'fa:comments', + iconColor: 'black', + group: ['input'], + version: 1, + description: 'Send a message to a chat', + defaults: { + name: 'Respond to Chat', + }, + codex: { + categories: ['Core Nodes', 'HITL'], + subcategories: { + HITL: ['Human in the Loop'], + }, + alias: ['human', 'wait', 'hitl'], + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.chat/', + }, + ], + }, + }, + inputs: `={{ (${configureInputs})($parameter) }}`, + outputs: [NodeConnectionTypes.Main], + properties: [ + { + displayName: + "Verify you're using a chat trigger with the 'Response Mode' option set to 'Using Response Nodes'", + name: 'generalNotice', + type: 'notice', + default: '', + }, + { + displayName: 'Message', + name: 'message', + type: 'string', + default: '', + required: true, + typeOptions: { + rows: 6, + }, + }, + { + displayName: 'Wait for User Reply', + name: CHAT_WAIT_USER_REPLY, + type: 'boolean', + default: true, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Add Memory Input Connection', + name: 'memoryConnection', + type: 'boolean', + default: false, + }, + limitWaitTimeOption, + ], + }, + ], + }; + + async onMessage( + context: IExecuteFunctions, + data: INodeExecutionData, + ): Promise { + const options = context.getNodeParameter('options', 0, {}) as { + memoryConnection?: boolean; + }; + + const waitForReply = context.getNodeParameter(CHAT_WAIT_USER_REPLY, 0, true) as boolean; + + if (!waitForReply) { + const inputData = context.getInputData(); + return [inputData]; + } + + if (options.memoryConnection) { + const memory = (await context.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as + | BaseChatMemory + | undefined; + + const message = data.json?.chatInput; + + if (memory && message) { + await memory.chatHistory.addUserMessage(message as string); + } + } + + return [[data]]; + } + + async execute(this: IExecuteFunctions): Promise { + const connectedNodes = this.getParentNodes(this.getNode().name, { + includeNodeParameters: true, + }); + + const chatTrigger = connectedNodes.find( + (node) => node.type === CHAT_TRIGGER_NODE_TYPE && !node.disabled, + ); + + if (!chatTrigger) { + throw new NodeOperationError( + this.getNode(), + 'Workflow must be started from a chat trigger node', + ); + } + + const parameters = chatTrigger.parameters as { + mode?: 'hostedChat' | 'webhook'; + options: { responseMode: 'lastNode' | 'responseNodes' | 'streaming' | 'responseNode' }; + }; + + if (parameters.mode === 'webhook') { + throw new NodeOperationError( + this.getNode(), + '"Embeded chat" is not supported, change the "Mode" in the chat trigger node to the "Hosted Chat"', + ); + } + + if (parameters.options.responseMode !== 'responseNodes') { + throw new NodeOperationError( + this.getNode(), + '"Response Mode" in the chat trigger node must be set to "Respond Nodes"', + ); + } + + const message = (this.getNodeParameter('message', 0) as string) ?? ''; + const options = this.getNodeParameter('options', 0, {}) as { + memoryConnection?: boolean; + }; + + if (options.memoryConnection) { + const memory = (await this.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as + | BaseChatMemory + | undefined; + + if (memory) { + await memory.chatHistory.addAIChatMessage(message); + } + } + + const waitTill = configureWaitTillDate(this); + + await this.putExecutionToWait(waitTill); + return [[{ json: {}, sendMessage: message }]]; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts index a1c36f8db73..938f03a2329 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts @@ -35,27 +35,30 @@ const allowedFileMimeTypeOption: INodeProperties = { 'Allowed file types for upload. Comma-separated list of MIME types.', }; -const responseModeOptions = [ - { - name: 'When Last Node Finishes', - value: 'lastNode', - description: 'Returns data of the last-executed node', - }, - { - name: "Using 'Respond to Webhook' Node", - value: 'responseNode', - description: 'Response defined in that node', - }, -]; +const respondToWebhookResponseMode = { + name: "Using 'Respond to Webhook' Node", + value: 'responseNode', + description: 'Response defined in that node', +}; -const responseModeWithStreamingOptions = [ - ...responseModeOptions, - { - name: 'Streaming Response', - value: 'streaming', - description: 'Streaming response from specified nodes (e.g. Agents)', - }, -]; +const lastNodeResponseMode = { + name: 'When Last Node Finishes', + value: 'lastNode', + description: 'Returns data of the last-executed node', +}; + +const streamingResponseMode = { + name: 'Streaming Response', + value: 'streaming', + description: 'Streaming response from specified nodes (e.g. Agents)', +}; + +const respondNodesResponseMode = { + name: 'Using Response Nodes', + value: 'responseNodes', + description: + "Send responses to the chat by using 'Respond to Chat' or 'Respond to Webhook' nodes", +}; const commonOptionsFields: INodeProperties[] = [ // CORS parameters are only valid for when chat is used in hosted or webhook mode @@ -209,9 +212,8 @@ export class ChatTrigger extends Node { icon: 'fa:comments', iconColor: 'black', group: ['trigger'], - version: [1, 1.1, 1.2], - // Keep the default version as 1.1 to avoid releasing streaming in broken state - defaultVersion: 1.1, + version: [1, 1.1, 1.2, 1.3], + defaultVersion: 1.3, description: 'Runs the workflow when an n8n generated webchat is submitted', defaults: { name: 'When chat message received', @@ -390,7 +392,7 @@ export class ChatTrigger extends Node { displayOptions: { show: { public: [false], - '@version': [{ _cnd: { gte: 1.1 } }], + '@version': [1, 1.1], }, }, placeholder: 'Add Field', @@ -417,13 +419,13 @@ export class ChatTrigger extends Node { displayName: 'Response Mode', name: 'responseMode', type: 'options', - options: responseModeOptions, + options: [lastNodeResponseMode, respondToWebhookResponseMode], default: 'lastNode', description: 'When and how to respond to the webhook', }, ], }, - // Options for version 1.2+ (with streaming) + // Options for version 1.2 (with streaming) { displayName: 'Options', name: 'options', @@ -432,7 +434,7 @@ export class ChatTrigger extends Node { show: { mode: ['hostedChat', 'webhook'], public: [true], - '@version': [{ _cnd: { gte: 1.2 } }], + '@version': [1.2], }, }, placeholder: 'Add Field', @@ -443,12 +445,72 @@ export class ChatTrigger extends Node { displayName: 'Response Mode', name: 'responseMode', type: 'options', - options: responseModeWithStreamingOptions, + options: [lastNodeResponseMode, respondToWebhookResponseMode, streamingResponseMode], default: 'lastNode', description: 'When and how to respond to the webhook', }, ], }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + public: [false], + '@version': [{ _cnd: { gte: 1.3 } }], + }, + }, + placeholder: 'Add Field', + default: {}, + options: [ + allowFileUploadsOption, + allowedFileMimeTypeOption, + { + displayName: 'Response Mode', + name: 'responseMode', + type: 'options', + options: [lastNodeResponseMode, respondNodesResponseMode], + default: 'lastNode', + description: 'When and how to respond to the chat', + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + mode: ['hostedChat', 'webhook'], + public: [true], + '@version': [{ _cnd: { gte: 1.3 } }], + }, + }, + placeholder: 'Add Field', + default: {}, + options: [ + ...commonOptionsFields, + { + displayName: 'Response Mode', + name: 'responseMode', + type: 'options', + options: [lastNodeResponseMode, respondToWebhookResponseMode], + default: 'lastNode', + description: 'When and how to respond to the chat', + displayOptions: { show: { '/mode': ['webhook'] } }, + }, + { + displayName: 'Response Mode', + name: 'responseMode', + type: 'options', + options: [lastNodeResponseMode, respondNodesResponseMode], + default: 'lastNode', + description: 'When and how to respond to the webhook', + displayOptions: { show: { '/mode': ['hostedChat'] } }, + }, + ], + }, ], }; @@ -536,10 +598,10 @@ export class ChatTrigger extends Node { allowFileUploads?: boolean; allowedFilesMimeTypes?: string; customCss?: string; + responseMode?: string; }; - const responseMode = ctx.getNodeParameter('options.responseMode', 'lastNode') as string; - const enableStreaming = responseMode === 'streaming'; + const enableStreaming = options.responseMode === 'streaming'; const req = ctx.getRequestObject(); const webhookName = ctx.getWebhookName(); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/Chat.node.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/Chat.node.test.ts new file mode 100644 index 00000000000..dc47838eb1a --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/Chat.node.test.ts @@ -0,0 +1,143 @@ +import type { MockProxy } from 'jest-mock-extended'; +import { mock } from 'jest-mock-extended'; +import type { INode, IExecuteFunctions } from 'n8n-workflow'; +import { CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow'; + +import { Chat } from '../Chat.node'; + +describe('Test Chat Node', () => { + let chat: Chat; + let mockExecuteFunctions: MockProxy; + + const chatNode = mock({ + name: 'Chat', + type: CHAT_TRIGGER_NODE_TYPE, + parameters: {}, + }); + + beforeEach(() => { + chat = new Chat(); + mockExecuteFunctions = mock(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should execute and send message', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ + limitType: 'afterTimeInterval', + resumeAmount: 1, + resumeUnit: 'minutes', + }); + mockExecuteFunctions.getNode.mockReturnValue(chatNode); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + { + type: CHAT_TRIGGER_NODE_TYPE, + disabled: false, + parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } }, + } as any, + ]); + + const result = await chat.execute.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]); + }); + + it('should execute and handle memory connection', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ memoryConnection: true }); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ + limitType: 'afterTimeInterval', + resumeAmount: 1, + resumeUnit: 'minutes', + }); + mockExecuteFunctions.getNode.mockReturnValue(chatNode); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + { + type: CHAT_TRIGGER_NODE_TYPE, + disabled: false, + parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } }, + } as any, + ]); + + const memory = { chatHistory: { addAIChatMessage: jest.fn() } }; + mockExecuteFunctions.getInputConnectionData.mockResolvedValueOnce(memory); + + await chat.execute.call(mockExecuteFunctions); + + expect(memory.chatHistory.addAIChatMessage).toHaveBeenCalledWith('message'); + }); + + it('should execute without memory connection', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ + limitType: 'afterTimeInterval', + resumeAmount: 1, + resumeUnit: 'minutes', + }); + mockExecuteFunctions.getNode.mockReturnValue(chatNode); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + { + type: CHAT_TRIGGER_NODE_TYPE, + disabled: false, + parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } }, + } as any, + ]); + + const result = await chat.execute.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]); + }); + + it('should execute with specified time limit', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ + limitType: 'atSpecifiedTime', + maxDateAndTime: new Date().toISOString(), + }); + mockExecuteFunctions.getNode.mockReturnValue(chatNode); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + { + type: CHAT_TRIGGER_NODE_TYPE, + disabled: false, + parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } }, + } as any, + ]); + + const result = await chat.execute.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]); + }); + + it('should process onMessage without waiting for reply', async () => { + const data = { json: { chatInput: 'user message' } }; + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ memoryConnection: true }); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false); + mockExecuteFunctions.getInputData.mockReturnValue([data]); + mockExecuteFunctions.getNode.mockReturnValue(chatNode); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + { + type: CHAT_TRIGGER_NODE_TYPE, + disabled: false, + parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } }, + } as any, + ]); + + const result = await chat.onMessage(mockExecuteFunctions, data); + + expect(result).toEqual([[data]]); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts index 1dd55ff0b5b..a9480c7fa91 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts @@ -150,8 +150,7 @@ describe('ChatTrigger Node', () => { ): boolean | string | object | undefined => { if (paramName === 'public') return true; if (paramName === 'mode') return 'hostedChat'; - if (paramName === 'options') return {}; - if (paramName === 'options.responseMode') return 'streaming'; + if (paramName === 'options') return { responseMode: 'streaming' }; return defaultValue; }, ); @@ -184,8 +183,7 @@ describe('ChatTrigger Node', () => { ): boolean | string | object | undefined => { if (paramName === 'public') return true; if (paramName === 'mode') return 'hostedChat'; - if (paramName === 'options') return {}; - if (paramName === 'options.responseMode') return 'lastNode'; + if (paramName === 'options') return { responseMode: 'lastNode' }; return defaultValue; }, ); @@ -220,8 +218,7 @@ describe('ChatTrigger Node', () => { ): boolean | string | object | undefined => { if (paramName === 'public') return true; if (paramName === 'mode') return 'hostedChat'; - if (paramName === 'options') return {}; - if (paramName === 'options.responseMode') return 'streaming'; + if (paramName === 'options') return { responseMode: 'streaming' }; return defaultValue; }, ); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts index 7b5d4964f47..a10f377aea8 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts @@ -77,7 +77,7 @@ export function createPage({ + + + + diff --git a/packages/frontend/editor-ui/src/components/SqlEditor/SqlEditor.vue b/packages/frontend/editor-ui/src/components/SqlEditor/SqlEditor.vue index c6905816b9e..ea5fe859e29 100644 --- a/packages/frontend/editor-ui/src/components/SqlEditor/SqlEditor.vue +++ b/packages/frontend/editor-ui/src/components/SqlEditor/SqlEditor.vue @@ -36,6 +36,7 @@ import { expressionCloseBrackets, expressionCloseBracketsConfig, } from '@/plugins/codemirror/expressionCloseBrackets'; +import type { TargetNodeParameterContext } from '@/Interface'; const SQL_DIALECTS = { StandardSQL, @@ -54,6 +55,7 @@ type Props = { rows?: number; isReadOnly?: boolean; fullscreen?: boolean; + targetNodeParameterContext?: TargetNodeParameterContext; }; const props = withDefaults(defineProps(), { @@ -61,6 +63,7 @@ const props = withDefaults(defineProps(), { rows: 4, isReadOnly: false, fullscreen: false, + targetNodeParameterContext: undefined, }); const emit = defineEmits<{ @@ -124,6 +127,7 @@ const { extensions, skipSegments: ['Statement', 'CompositeIdentifier', 'Parens', 'Brackets'], isReadOnly: props.isReadOnly, + targetNodeParameterContext: props.targetNodeParameterContext, onChange: () => { emit('update:model-value', readEditorValue()); }, diff --git a/packages/frontend/editor-ui/src/components/__snapshots__/InputPanel.test.ts.snap b/packages/frontend/editor-ui/src/components/__snapshots__/InputPanel.test.ts.snap index aefdd8a7da0..4962c2a32f4 100644 --- a/packages/frontend/editor-ui/src/components/__snapshots__/InputPanel.test.ts.snap +++ b/packages/frontend/editor-ui/src/components/__snapshots__/InputPanel.test.ts.snap @@ -7,7 +7,6 @@ exports[`InputPanel > should render 1`] = ` data-test-id="ndv-input-panel" data-v-2e5cd75c="" > -
should render 1`] = `
- - - - - - - - +
+ + + + + + + + +
+
diff --git a/packages/frontend/editor-ui/src/components/canvas/experimental/components/ExperimentalEmbeddedNodeDetails.vue b/packages/frontend/editor-ui/src/components/canvas/experimental/components/ExperimentalEmbeddedNodeDetails.vue index 32adad237cb..f83c87b8c00 100644 --- a/packages/frontend/editor-ui/src/components/canvas/experimental/components/ExperimentalEmbeddedNodeDetails.vue +++ b/packages/frontend/editor-ui/src/components/canvas/experimental/components/ExperimentalEmbeddedNodeDetails.vue @@ -114,6 +114,7 @@ const expressionResolveCtx = computed nodeName, additionalKeys: {}, inputNode: findInputNode(), + connections: workflowsStore.connectionsBySourceNode, }; }); diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts index 72a08e8232d..e0ad11087cd 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts @@ -216,6 +216,8 @@ describe('useCanvasMapping', () => { }); it('should handle input and output connections', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); const nodes = [manualTriggerNode, setNode]; const connections = { @@ -225,6 +227,9 @@ describe('useCanvasMapping', () => { ], }, }; + + workflowsStore.workflow.connections = connections; + const workflowObject = createTestWorkflowObject({ nodes, connections, diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts index 24358bf0638..28d71b596cf 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts @@ -59,6 +59,7 @@ import { useNodeHelpers } from './useNodeHelpers'; import { getTriggerNodeServiceName } from '@/utils/nodeTypesUtils'; import { useNodeDirtiness } from '@/composables/useNodeDirtiness'; import { getNodeIconSource } from '../utils/nodeIcon'; +import * as workflowUtils from 'n8n-workflow/common'; export function useCanvasMapping({ nodes, @@ -571,56 +572,62 @@ export function useCanvasMapping({ }, {}); }); - const mappedNodes = computed(() => [ - ...nodes.value.map((node) => { - const inputConnections = workflowObject.value.connectionsByDestinationNode[node.name] ?? {}; - const outputConnections = workflowObject.value.connectionsBySourceNode[node.name] ?? {}; + const mappedNodes = computed(() => { + const connectionsBySourceNode = connections.value; + const connectionsByDestinationNode = + workflowUtils.mapConnectionsByDestination(connectionsBySourceNode); - const data: CanvasNodeData = { - id: node.id, - name: node.name, - subtitle: nodeSubtitleById.value[node.id] ?? '', - type: node.type, - typeVersion: node.typeVersion, - disabled: node.disabled, - inputs: nodeInputsById.value[node.id] ?? [], - outputs: nodeOutputsById.value[node.id] ?? [], - connections: { - [CanvasConnectionMode.Input]: inputConnections, - [CanvasConnectionMode.Output]: outputConnections, - }, - issues: { - items: nodeIssuesById.value[node.id], - visible: nodeHasIssuesById.value[node.id], - }, - pinnedData: { - count: nodePinnedDataById.value[node.id]?.length ?? 0, - visible: !!nodePinnedDataById.value[node.id], - }, - execution: { - status: nodeExecutionStatusById.value[node.id], - waiting: nodeExecutionWaitingById.value[node.id], - waitingForNext: nodeExecutionWaitingForNextById.value[node.id], - running: nodeExecutionRunningById.value[node.id], - }, - runData: { - outputMap: nodeExecutionRunDataOutputMapById.value[node.id], - iterations: nodeExecutionRunDataById.value[node.id]?.length ?? 0, - visible: !!nodeExecutionRunDataById.value[node.id], - }, - render: renderTypeByNodeId.value[node.id] ?? { type: 'default', options: {} }, - }; + return [ + ...nodes.value.map((node) => { + const outputConnections = connectionsBySourceNode[node.name] ?? {}; + const inputConnections = connectionsByDestinationNode[node.name] ?? {}; - return { - id: node.id, - label: node.name, - type: 'canvas-node', - position: { x: node.position[0], y: node.position[1] }, - data, - ...additionalNodePropertiesById.value[node.id], - }; - }), - ]); + const data: CanvasNodeData = { + id: node.id, + name: node.name, + subtitle: nodeSubtitleById.value[node.id] ?? '', + type: node.type, + typeVersion: node.typeVersion, + disabled: node.disabled, + inputs: nodeInputsById.value[node.id] ?? [], + outputs: nodeOutputsById.value[node.id] ?? [], + connections: { + [CanvasConnectionMode.Input]: inputConnections, + [CanvasConnectionMode.Output]: outputConnections, + }, + issues: { + items: nodeIssuesById.value[node.id], + visible: nodeHasIssuesById.value[node.id], + }, + pinnedData: { + count: nodePinnedDataById.value[node.id]?.length ?? 0, + visible: !!nodePinnedDataById.value[node.id], + }, + execution: { + status: nodeExecutionStatusById.value[node.id], + waiting: nodeExecutionWaitingById.value[node.id], + waitingForNext: nodeExecutionWaitingForNextById.value[node.id], + running: nodeExecutionRunningById.value[node.id], + }, + runData: { + outputMap: nodeExecutionRunDataOutputMapById.value[node.id], + iterations: nodeExecutionRunDataById.value[node.id]?.length ?? 0, + visible: !!nodeExecutionRunDataById.value[node.id], + }, + render: renderTypeByNodeId.value[node.id] ?? { type: 'default', options: {} }, + }; + + return { + id: node.id, + label: node.name, + type: 'canvas-node', + position: { x: node.position[0], y: node.position[1] }, + data, + ...additionalNodePropertiesById.value[node.id], + }; + }), + ]; + }); const mappedConnections = computed(() => { return mapLegacyConnectionsToCanvasConnections(connections.value ?? [], nodes.value ?? []).map( diff --git a/packages/frontend/editor-ui/src/composables/useRunWorkflow.test.ts b/packages/frontend/editor-ui/src/composables/useRunWorkflow.test.ts index bca341d68f4..ed0d6238df2 100644 --- a/packages/frontend/editor-ui/src/composables/useRunWorkflow.test.ts +++ b/packages/frontend/editor-ui/src/composables/useRunWorkflow.test.ts @@ -1044,6 +1044,7 @@ describe('useRunWorkflow({ router })', () => { workflowsStore.activeWorkflows = ['test-wf-id']; workflowsStore.setActiveExecutionId('test-exec-id'); + workflowsStore.executionWaitingForWebhook = false; getExecutionSpy.mockResolvedValue(executionData); diff --git a/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts b/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts index 88d43db1bc7..1c6fd8a6951 100644 --- a/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts @@ -253,6 +253,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { const inputName = 'main'; const runIndex = 0; - const result = executeData(parentNodes, currentNode, inputName, runIndex); + const result = executeData({}, parentNodes, currentNode, inputName, runIndex); expect(result).toEqual({ node: {}, @@ -538,18 +535,15 @@ describe('useWorkflowHelpers', () => { const jsonData = { name: 'Test', }; - workflowsStore.getCurrentWorkflow.mockReturnValue({ - connectionsByDestinationNode: { - Set: { - main: [ - [ - { node: 'Start', index: 0, type: 'main' }, - { node: 'Set', index: 0, type: 'main' }, - ], - ], - }, + + const connectionsBySourceNode: IConnections = { + Start: { + main: [[{ node: 'Set', index: 0, type: 'main' }]], }, - } as never); + Set: { + main: [[{ node: 'Start', index: 0, type: 'main' }]], + }, + }; workflowsStore.workflowExecutionData = { data: { @@ -575,7 +569,13 @@ describe('useWorkflowHelpers', () => { }, } as unknown as IExecutionResponse; - const result = executeData(parentNodes, currentNode, inputName, runIndex); + const result = executeData( + connectionsBySourceNode, + parentNodes, + currentNode, + inputName, + runIndex, + ); expect(result).toEqual({ node: {}, @@ -609,18 +609,15 @@ describe('useWorkflowHelpers', () => { const jsonData = { name: 'Test', }; - workflowsStore.getCurrentWorkflow.mockReturnValue({ - connectionsByDestinationNode: { - Set: { - main: [ - [ - { node: 'Start', index: 0, type: 'main' }, - { node: 'Set', index: 0, type: 'main' }, - ], - ], - }, + + const connectionsBySourceNode: IConnections = { + Start: { + main: [[{ node: 'Set', index: 0, type: 'main' }]], }, - } as never); + Set: { + main: [[{ node: 'Start', index: 0, type: 'main' }]], + }, + }; workflowsStore.workflowExecutionData = { data: { @@ -646,7 +643,13 @@ describe('useWorkflowHelpers', () => { }, } as unknown as IExecutionResponse; - const result = executeData(parentNodes, currentNode, inputName, runIndex); + const result = executeData( + connectionsBySourceNode, + parentNodes, + currentNode, + inputName, + runIndex, + ); expect(result).toEqual({ node: {}, @@ -686,22 +689,20 @@ describe('useWorkflowHelpers', () => { name: 'Test B', }; - workflowsStore.getCurrentWorkflow.mockReturnValue({ - connectionsByDestinationNode: { - Set: { - main: [ - [ - { node: 'Parent A', index: 0, type: 'main' }, - { node: 'Set', index: 0, type: 'main' }, - ], - [ - { node: 'Parent B', index: 0, type: 'main' }, - { node: 'Set', index: 0, type: 'main' }, - ], - ], - }, + const connectionsBySourceNode: IConnections = { + 'Parent A': { + main: [[{ node: 'Set', type: 'main', index: 0 }]], }, - } as never); + 'Parent B': { + main: [[{ node: 'Set', type: 'main', index: 1 }]], + }, + Set: { + main: [ + [{ node: 'Set', type: 'main', index: 0 }], + [{ node: 'Set', type: 'main', index: 1 }], + ], + }, + }; workflowsStore.workflowExecutionData = { data: { @@ -742,7 +743,13 @@ describe('useWorkflowHelpers', () => { }, } as unknown as IExecutionResponse; - const result = executeData(parentNodes, currentNode, inputName, runIndex); + const result = executeData( + connectionsBySourceNode, + parentNodes, + currentNode, + inputName, + runIndex, + ); expect(result).toEqual({ node: {}, @@ -779,7 +786,7 @@ describe('useWorkflowHelpers', () => { }; workflowsStore.shouldReplaceInputDataWithPinData = true; - const result = executeData(parentNodes, currentNode, inputName, runIndex); + const result = executeData({}, parentNodes, currentNode, inputName, runIndex); expect(result.data).toEqual({ main: [[{ json: { key: 'value' } }]] }); expect(result.source).toEqual({ main: [{ previousNode: 'ParentNode' }] }); @@ -802,20 +809,23 @@ describe('useWorkflowHelpers', () => { } as never, ], }; - workflowsStore.getCurrentWorkflow.mockReturnValue({ - connectionsByDestinationNode: { - CurrentNode: { - main: [ - [ - { node: 'ParentNode', index: 0, type: 'main' }, - { node: 'CurrentNode', index: 0, type: 'main' }, - ], - ], - }, - }, - } as never); - const result = executeData(parentNodes, currentNode, inputName, runIndex); + const connectionsBySourceNode: IConnections = { + CurrentNode: { + main: [[{ node: 'CurrentNode', index: 0, type: 'main' }]], + }, + ParentNode: { + main: [[{ node: 'CurrentNode', index: 0, type: 'main' }]], + }, + }; + + const result = executeData( + connectionsBySourceNode, + parentNodes, + currentNode, + inputName, + runIndex, + ); expect(result.data).toEqual({ main: [[{ json: { key: 'valueFromRunData' } }]] }); expect(result.source).toEqual({ @@ -841,20 +851,24 @@ describe('useWorkflowHelpers', () => { } as never, ], }; - workflowsStore.getCurrentWorkflow.mockReturnValue({ - connectionsByDestinationNode: { - CurrentNode: { - main: [ - [ - { node: 'ParentNode', index: 1, type: 'main' }, - { node: 'CurrentNode', index: 0, type: 'main' }, - ], - ], - }, - }, - } as never); - const result = executeData(parentNodes, currentNode, inputName, runIndex, parentRunIndex); + const connectionsBySourceNode: IConnections = { + CurrentNode: { + main: [[{ node: 'CurrentNode', index: 0, type: 'main' }]], + }, + ParentNode: { + main: [[], [{ node: 'CurrentNode', index: 1, type: 'main' }]], + }, + }; + + const result = executeData( + connectionsBySourceNode, + parentNodes, + currentNode, + inputName, + runIndex, + parentRunIndex, + ); expect(result.data).toEqual({ main: [[{ json: { key: 'valueFromRunData' } }]] }); expect(result.source).toEqual({ @@ -874,7 +888,7 @@ describe('useWorkflowHelpers', () => { workflowsStore.shouldReplaceInputDataWithPinData = false; workflowsStore.getWorkflowRunData = null; - const result = executeData(parentNodes, currentNode, inputName, runIndex); + const result = executeData({}, parentNodes, currentNode, inputName, runIndex); expect(result.data).toEqual({}); expect(result.source).toBeNull(); diff --git a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts index c7e09831444..9bdbc3daf4f 100644 --- a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts +++ b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts @@ -29,6 +29,7 @@ import { NodeHelpers, WEBHOOK_NODE_TYPE, } from 'n8n-workflow'; +import * as workflowUtils from 'n8n-workflow/common'; import type { ICredentialsResponse, @@ -71,6 +72,7 @@ export type ResolveParameterOptions = { additionalKeys?: IWorkflowDataProxyAdditionalKeys; isForCredential?: boolean; contextNodeName?: string; + connections?: IConnections; }; export function resolveParameter( @@ -81,6 +83,7 @@ export function resolveParameter( return resolveParameterImpl( parameter, () => opts.workflow, + opts.connections, opts.envVars, opts.workflow.getNode(opts.nodeName), opts.execution, @@ -100,6 +103,7 @@ export function resolveParameter( return resolveParameterImpl( parameter, workflowsStore.getCurrentWorkflow, + workflowsStore.connectionsBySourceNode, useEnvironmentsStore().variablesAsObject, useNDVStore().activeNode, workflowsStore.workflowExecutionData, @@ -113,6 +117,7 @@ export function resolveParameter( function resolveParameterImpl( parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], getContextWorkflow: () => Workflow, + connections: IConnections, envVars: Record, ndvActiveNode: INodeUi | null, executionData: IExecutionResponse | null, @@ -200,11 +205,11 @@ function resolveParameterImpl( } let _connectionInputData = connectionInputData( + connections, parentNode, contextNode!.name, inputName, runIndexParent, - getContextWorkflow, shouldReplaceInputDataWithPinData, pinData, executionData?.data?.resultData.runData ?? null, @@ -215,11 +220,11 @@ function resolveParameterImpl( // For Sub-Nodes connected to Trigger-Nodes use the data of the root-node // (Gets for example used by the Memory connected to the Chat-Trigger-Node) const _executeData = executeDataImpl( + connections, [contextNode.name], contextNode.name, inputName, 0, - getContextWorkflow, shouldReplaceInputDataWithPinData, pinData, executionData?.data?.resultData.runData ?? null, @@ -265,11 +270,11 @@ function resolveParameterImpl( runIndexCurrent = workflowRunData[contextNode!.name].length - 1; } let _executeData = executeDataImpl( + connections, parentNode, contextNode!.name, inputName, runIndexCurrent, - getContextWorkflow, shouldReplaceInputDataWithPinData, pinData, executionData?.data?.resultData.runData ?? null, @@ -279,11 +284,11 @@ function resolveParameterImpl( if (!_executeData.source) { // fallback to parent's run index for multi-output case _executeData = executeDataImpl( + connections, parentNode, contextNode!.name, inputName, runIndexParent, - getContextWorkflow, shouldReplaceInputDataWithPinData, pinData, executionData?.data?.resultData.runData ?? null, @@ -310,6 +315,7 @@ export function resolveRequiredParameters( currentParameter: INodeProperties, parameters: INodeParameters, opts: { + connections?: IConnections; targetItem?: TargetItem; inputNodeName?: string; inputRunIndex?: number; @@ -382,11 +388,11 @@ function getNodeTypes(): INodeTypes { // TODO: move to separate file // Returns connectionInputData to be able to execute an expression. function connectionInputData( + connections: IConnections, parentNode: string[], currentNode: string, inputName: string, runIndex: number, - getContextWorkflow: () => Workflow, shouldReplaceInputDataWithPinData: boolean, pinData: IPinData | undefined, workflowRunData: IRunData | null, @@ -394,11 +400,11 @@ function connectionInputData( ): INodeExecutionData[] | null { let connectionInputData: INodeExecutionData[] | null = null; const _executeData = executeDataImpl( + connections, parentNode, currentNode, inputName, runIndex, - getContextWorkflow, shouldReplaceInputDataWithPinData, pinData, workflowRunData, @@ -431,6 +437,7 @@ function connectionInputData( } export function executeData( + connections: IConnections, parentNodes: string[], currentNode: string, inputName: string, @@ -440,11 +447,11 @@ export function executeData( const workflowsStore = useWorkflowsStore(); return executeDataImpl( + connections, parentNodes, currentNode, inputName, runIndex, - workflowsStore.getCurrentWorkflow, workflowsStore.shouldReplaceInputDataWithPinData, workflowsStore.pinnedWorkflowData, workflowsStore.getWorkflowRunData, @@ -454,16 +461,18 @@ export function executeData( // TODO: move to separate file function executeDataImpl( + connections: IConnections, parentNodes: string[], currentNode: string, inputName: string, runIndex: number, - getContextWorkflow: () => Workflow, shouldReplaceInputDataWithPinData: boolean, pinData: IPinData | undefined, workflowRunData: IRunData | null, parentRunIndex?: number, ): IExecuteData { + const connectionsByDestinationNode = workflowUtils.mapConnectionsByDestination(connections); + const executeData = { node: {}, data: {}, @@ -507,15 +516,12 @@ function executeDataImpl( [inputName]: workflowRunData[currentNode][runIndex].source, }; } else { - const workflow = getContextWorkflow(); - let previousNodeOutput: number | undefined; // As the node can be connected through either of the outputs find the correct one // and set it to make pairedItem work on not executed nodes - if (workflow.connectionsByDestinationNode[currentNode]?.main) { - mainConnections: for (const mainConnections of workflow.connectionsByDestinationNode[ - currentNode - ].main) { + if (connectionsByDestinationNode[currentNode]?.main) { + mainConnections: for (const mainConnections of connectionsByDestinationNode[currentNode] + .main) { for (const connection of mainConnections ?? []) { if ( connection.type === NodeConnectionTypes.Main && diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index e0df2fcafa6..c68c4d2be83 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -155,6 +155,7 @@ export const MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger'; export const MANUAL_CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.manualChatTrigger'; export const MCP_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.mcpTrigger'; export const CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTrigger'; +export const CHAT_NODE_TYPE = '@n8n/n8n-nodes-langchain.chat'; export const AGENT_NODE_TYPE = '@n8n/n8n-nodes-langchain.agent'; export const OPEN_AI_NODE_TYPE = '@n8n/n8n-nodes-langchain.openAi'; export const OPEN_AI_NODE_MESSAGE_ASSISTANT_TYPE = diff --git a/packages/frontend/editor-ui/src/features/logs/__test__/useChatMessaging.test.ts b/packages/frontend/editor-ui/src/features/logs/__test__/useChatMessaging.test.ts new file mode 100644 index 00000000000..b3e4406f081 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/logs/__test__/useChatMessaging.test.ts @@ -0,0 +1,125 @@ +import { createTestingPinia } from '@pinia/testing'; +import { useChatMessaging } from '../composables/useChatMessaging'; +import { ref, computed } from 'vue'; +import type { Ref, ComputedRef } from 'vue'; +import type { IRunExecutionData } from 'n8n-workflow'; +import type { IExecutionPushResponse, INodeUi } from '@/Interface'; +import type { RunWorkflowChatPayload } from '../composables/useChatMessaging'; +import { vi } from 'vitest'; +import type { ChatMessage } from '@n8n/chat/types'; + +vi.mock('../logs.utils', () => { + return { + extractBotResponse: vi.fn(() => 'Last node response'), + getInputKey: vi.fn(), + processFiles: vi.fn(), + }; +}); + +describe('useChatMessaging', () => { + let chatMessaging: ReturnType; + let chatTrigger: Ref; + let messages: Ref; + let sessionId: Ref; + let executionResultData: ComputedRef; + let onRunChatWorkflow: ( + payload: RunWorkflowChatPayload, + ) => Promise; + let ws: Ref; + let executionData: IRunExecutionData['resultData'] | undefined = undefined; + + beforeEach(() => { + executionData = undefined; + createTestingPinia(); + chatTrigger = ref(null); + messages = ref([]); + sessionId = ref('session-id'); + executionResultData = computed(() => executionData); + onRunChatWorkflow = vi.fn().mockResolvedValue({ + executionId: 'execution-id', + } as IExecutionPushResponse); + ws = ref(null); + + chatMessaging = useChatMessaging({ + chatTrigger, + messages, + sessionId, + executionResultData, + onRunChatWorkflow, + ws, + }); + }); + + it('should initialize correctly', () => { + expect(chatMessaging).toBeDefined(); + expect(chatMessaging.previousMessageIndex.value).toBe(0); + expect(chatMessaging.isLoading.value).toBe(false); + }); + + it('should send a message and add it to messages', async () => { + const messageText = 'Hello, world!'; + await chatMessaging.sendMessage(messageText); + + expect(messages.value).toHaveLength(1); + }); + + it('should send message via WebSocket if open', async () => { + const messageText = 'Hello, WebSocket!'; + ws.value = { + readyState: WebSocket.OPEN, + send: vi.fn(), + } as unknown as WebSocket; + + await chatMessaging.sendMessage(messageText); + + expect(ws.value.send).toHaveBeenCalledWith( + JSON.stringify({ + sessionId: sessionId.value, + action: 'sendMessage', + chatInput: messageText, + }), + ); + }); + + it('should startWorkflowWithMessage and add message to messages with final message', async () => { + const messageText = 'Hola!'; + chatTrigger.value = { + id: 'trigger-id', + name: 'Trigger', + typeVersion: 1.1, + parameters: { options: {} }, + } as unknown as INodeUi; + + (onRunChatWorkflow as jest.Mock).mockResolvedValue({ + executionId: 'execution-id', + } as IExecutionPushResponse); + + executionData = { + runData: {}, + } as unknown as IRunExecutionData['resultData']; + + await chatMessaging.sendMessage(messageText); + expect(messages.value).toHaveLength(2); + }); + + it('should startWorkflowWithMessage and not add final message if responseMode is responseNode and version is 1.3', async () => { + const messageText = 'Hola!'; + chatTrigger.value = { + id: 'trigger-id', + name: 'Trigger', + typeVersion: 1.3, + parameters: { options: { responseMode: 'responseNodes' } }, + } as unknown as INodeUi; + + (onRunChatWorkflow as jest.Mock).mockResolvedValue({ + executionId: 'execution-id', + } as IExecutionPushResponse); + + executionData = { + runData: {}, + } as unknown as IRunExecutionData['resultData']; + + await chatMessaging.sendMessage(messageText); + expect(messages.value).toHaveLength(1); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogDetailsPanel.test.ts b/packages/frontend/editor-ui/src/features/logs/components/LogDetailsPanel.test.ts index 2dd5ebc254c..e566ee5d5e2 100644 --- a/packages/frontend/editor-ui/src/features/logs/components/LogDetailsPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/logs/components/LogDetailsPanel.test.ts @@ -1,19 +1,23 @@ -import { fireEvent, within } from '@testing-library/vue'; +import { fireEvent, waitFor, within } from '@testing-library/vue'; import { renderComponent } from '@/__tests__/render'; import LogDetailsPanel from './LogDetailsPanel.vue'; import { createRouter, createWebHistory } from 'vue-router'; import { createTestingPinia, type TestingPinia } from '@pinia/testing'; import { h } from 'vue'; import { + createMockNodeTypes, createTestNode, createTestTaskData, createTestWorkflow, createTestWorkflowObject, + defaultNodeTypes, + mockLoadedNodeType, } from '@/__tests__/mocks'; import { LOG_DETAILS_PANEL_STATE } from '@/features/logs/logs.constants'; import type { LogEntry } from '../logs.types'; import { createTestLogEntry } from '../__test__/mocks'; import { NodeConnectionTypes } from 'n8n-workflow'; +import { HTML_NODE_TYPE } from '@/constants'; describe('LogDetailsPanel', () => { let pinia: TestingPinia; @@ -182,4 +186,47 @@ describe('LogDetailsPanel', () => { ), ).toBeInTheDocument(); }); + + it('should render output data in HTML mode for HTML node', async () => { + const nodeA = createTestNode({ name: 'A' }); + const nodeB = createTestNode({ + name: 'B', + type: HTML_NODE_TYPE, + }); + const runDataA = createTestTaskData({ data: { [NodeConnectionTypes.Main]: [[{ json: {} }]] } }); + const runDataB = createTestTaskData({ + data: { [NodeConnectionTypes.Main]: [[{ json: { html: '

Hi!

' } }]] }, + source: [{ previousNode: 'A' }], + }); + const workflow = createTestWorkflowObject({ + nodes: [nodeA, nodeB], + nodeTypes: createMockNodeTypes({ + ...defaultNodeTypes, + [HTML_NODE_TYPE]: mockLoadedNodeType(HTML_NODE_TYPE), + }), + }); + const execution = { resultData: { runData: { A: [runDataA], B: [runDataB] } } }; + const logA = createLogEntry({ node: nodeA, runData: runDataA, workflow, execution }); + const logB = createLogEntry({ node: nodeB, runData: runDataB, workflow, execution }); + + // HACK: Setting parameters after creating workflow because validation removes parameters that are not define in node types. + nodeB.parameters = { operation: 'generateHtmlTemplate' }; + + const props = { + isOpen: true, + panels: LOG_DETAILS_PANEL_STATE.BOTH, + collapsingInputTableColumnName: null, + collapsingOutputTableColumnName: null, + }; + + const rendered = render({ ...props, logEntry: logB }); + + await waitFor(() => expect(rendered.container.querySelectorAll('iframe')).toHaveLength(1)); + await rendered.rerender({ ...props, logEntry: logA }); + await waitFor(() => expect(rendered.container.querySelectorAll('iframe')).toHaveLength(0)); + + // Re-selecting node B should render HTML again + await rendered.rerender({ ...props, logEntry: logB }); + await waitFor(() => expect(rendered.container.querySelectorAll('iframe')).toHaveLength(1)); + }); }); diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts index 3a03550dbfd..cb23949a836 100644 --- a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts @@ -658,6 +658,7 @@ describe('LogsPanel', () => { sendMessage: vi.fn(), previousMessageIndex: ref(0), isLoading: computed(() => false), + setLoadingState: vi.fn(), }; }); }); @@ -693,6 +694,7 @@ describe('LogsPanel', () => { sendMessage: vi.fn(), previousMessageIndex: ref(0), isLoading: computed(() => false), + setLoadingState: vi.fn(), }); logsStore.state = LOGS_PANEL_STATE.ATTACHED; @@ -800,6 +802,7 @@ describe('LogsPanel', () => { sendMessage: sendMessageSpy, previousMessageIndex: ref(0), isLoading: computed(() => false), + setLoadingState: vi.fn(), }; }); }); diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogsViewRunData.vue b/packages/frontend/editor-ui/src/features/logs/components/LogsViewRunData.vue index 53acb20b17e..33d3b2e98b7 100644 --- a/packages/frontend/editor-ui/src/features/logs/components/LogsViewRunData.vue +++ b/packages/frontend/editor-ui/src/features/logs/components/LogsViewRunData.vue @@ -75,6 +75,7 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) { v-if="runDataProps" v-bind="runDataProps" :key="`run-data${pipWindow ? '-pip' : ''}`" + :class="$style.component" :workflow="logEntry.workflow" :workflow-execution="logEntry.execution" :too-much-data-title="locale.baseText('ndv.output.tooMuchData.title')" @@ -130,6 +131,10 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {