From 18d91b614b4f7d7e386c08df6b6a618f77e37f2b Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Mon, 15 Sep 2025 08:06:02 +0200 Subject: [PATCH] fix(editor): Prevent tooltip flickering when a trigger node is pinned (#19233) --- .../frontend/editor-ui/src/__tests__/mocks.ts | 6 +- .../src/composables/useCanvasMapping.test.ts | 157 ++++++++++++++++-- .../src/composables/useCanvasMapping.ts | 5 +- 3 files changed, 152 insertions(+), 16 deletions(-) diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index af3587e1c0..217cc7a938 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -12,7 +12,7 @@ import type { INodeIssues, ITaskData, } from 'n8n-workflow'; -import { NodeConnectionTypes, NodeHelpers, Workflow } from 'n8n-workflow'; +import { FORM_TRIGGER_NODE_TYPE, NodeConnectionTypes, NodeHelpers, Workflow } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { mock } from 'vitest-mock-extended'; @@ -68,6 +68,7 @@ export const mockNodeTypeDescription = ({ hidden, description, webhooks, + eventTriggerDescription, }: { name?: INodeTypeDescription['name']; displayName?: INodeTypeDescription['displayName']; @@ -82,6 +83,7 @@ export const mockNodeTypeDescription = ({ hidden?: INodeTypeDescription['hidden']; description?: INodeTypeDescription['description']; webhooks?: INodeTypeDescription['webhooks']; + eventTriggerDescription?: INodeTypeDescription['eventTriggerDescription']; } = {}) => mock({ name, @@ -105,6 +107,7 @@ export const mockNodeTypeDescription = ({ webhooks, parameterPane: undefined, hidden, + eventTriggerDescription, }); export const mockLoadedNodeType = (name: string) => @@ -121,6 +124,7 @@ export const mockNodes = [ mockNode({ name: 'Code', type: CODE_NODE_TYPE }), mockNode({ name: 'Rename', type: SET_NODE_TYPE }), mockNode({ name: 'Chat Trigger', type: CHAT_TRIGGER_NODE_TYPE }), + mockNode({ name: 'Form Trigger', type: FORM_TRIGGER_NODE_TYPE }), mockNode({ name: 'Agent', type: AGENT_NODE_TYPE }), mockNode({ name: 'Sticky', type: STICKY_NODE_TYPE }), mockNode({ name: 'Simulate', type: SIMULATE_NODE_TYPE }), diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts index 79729c3360..ffa7e9597f 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts @@ -1,11 +1,9 @@ +import type { INode, NodeApiError, Workflow } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; +import { setActivePinia } from 'pinia'; import type { Ref } from 'vue'; import { ref } from 'vue'; -import { NodeConnectionTypes } from 'n8n-workflow'; -import type { Workflow, INode, NodeApiError } from 'n8n-workflow'; -import { setActivePinia } from 'pinia'; -import { useCanvasMapping } from '@/composables/useCanvasMapping'; -import type { INodeUi } from '@/Interface'; import { createTestNode, createTestWorkflowObject, @@ -13,16 +11,23 @@ import { mockNodes, mockNodeTypeDescription, } from '@/__tests__/mocks'; -import { STORES } from '@n8n/stores'; -import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants'; -import { useWorkflowsStore } from '@/stores/workflows.store'; -import { createCanvasConnectionHandleString, createCanvasConnectionId } from '@/utils/canvasUtils'; -import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types'; -import { MarkerType } from '@vue-flow/core'; -import { createTestingPinia } from '@pinia/testing'; import { mockedStore } from '@/__tests__/utils'; -import { mock } from 'vitest-mock-extended'; +import { useCanvasMapping } from '@/composables/useCanvasMapping'; +import { + FORM_TRIGGER_NODE_TYPE, + MANUAL_TRIGGER_NODE_TYPE, + SET_NODE_TYPE, + STICKY_NODE_TYPE, +} from '@/constants'; +import type { INodeUi } from '@/Interface'; +import { useWorkflowsStore } from '@/stores/workflows.store'; +import { CanvasConnectionMode, CanvasNodeRenderType, type CanvasNodeDefaultRender } from '@/types'; +import { createCanvasConnectionHandleString, createCanvasConnectionId } from '@/utils/canvasUtils'; +import { STORES } from '@n8n/stores'; import { useRootStore } from '@n8n/stores/useRootStore'; +import { createTestingPinia } from '@pinia/testing'; +import { MarkerType } from '@vue-flow/core'; +import { mock } from 'vitest-mock-extended'; beforeEach(() => { const pinia = createTestingPinia({ @@ -35,6 +40,14 @@ beforeEach(() => { [MANUAL_TRIGGER_NODE_TYPE]: { 1: mockNodeTypeDescription({ name: MANUAL_TRIGGER_NODE_TYPE, + group: ['trigger'], + }), + }, + [FORM_TRIGGER_NODE_TYPE]: { + 1: mockNodeTypeDescription({ + name: FORM_TRIGGER_NODE_TYPE, + group: ['trigger'], + eventTriggerDescription: 'Waiting for you to submit the form', }), }, [SET_NODE_TYPE]: { @@ -1690,6 +1703,124 @@ describe('useCanvasMapping', () => { }); }); + describe('trigger tooltip behavior with pinned data', () => { + it('should show tooltip for trigger node with no pinned data when workflow is running', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const triggerNode = mockNode({ + name: 'Manual Trigger', + type: MANUAL_TRIGGER_NODE_TYPE, + disabled: false, + }); + const nodesList = [triggerNode]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ + nodes: nodesList, + connections, + }); + + workflowsStore.isWorkflowRunning = true; + workflowsStore.getWorkflowRunData = {}; + workflowsStore.pinDataByNodeName.mockReturnValue(undefined); + + const { nodes: mappedNodes } = useCanvasMapping({ + nodes: ref(nodesList), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + const renderOptions = mappedNodes.value[0]?.data?.render as CanvasNodeDefaultRender; + expect(renderOptions.options.tooltip).toBe( + 'Waiting for you to create an event in n8n-nodes-base.manualTrigger', + ); + }); + + describe('when the node has a custom eventTriggerDescription', () => { + it('should show tooltip for trigger node with no pinned data when workflow is running', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const triggerNode = mockNode({ + name: 'Form Trigger', + type: FORM_TRIGGER_NODE_TYPE, + disabled: false, + }); + const nodesList = [triggerNode]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ + nodes: nodesList, + connections, + }); + + workflowsStore.isWorkflowRunning = true; + workflowsStore.getWorkflowRunData = {}; + workflowsStore.pinDataByNodeName.mockReturnValue(undefined); + + const { nodes: mappedNodes } = useCanvasMapping({ + nodes: ref(nodesList), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + const renderOptions = mappedNodes.value[0]?.data?.render as CanvasNodeDefaultRender; + expect(renderOptions.options.tooltip).toBe('Waiting for you to submit the form'); + }); + }); + + it('should not show tooltip for trigger node with pinned data when workflow is running', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const triggerNode = mockNode({ + name: 'Manual Trigger', + type: MANUAL_TRIGGER_NODE_TYPE, + disabled: false, + }); + const nodesList = [triggerNode]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ + nodes: nodesList, + connections, + }); + + workflowsStore.isWorkflowRunning = true; + workflowsStore.getWorkflowRunData = {}; + workflowsStore.pinDataByNodeName.mockReturnValue([{ json: {} }]); // Node has pinned data + + const { nodes: mappedNodes } = useCanvasMapping({ + nodes: ref(nodesList), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + const renderOptions = mappedNodes.value[0]?.data?.render as CanvasNodeDefaultRender; + expect(renderOptions.options.tooltip).toBeUndefined(); + }); + + it('should not show tooltip when workflow is not running', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const triggerNode = mockNode({ + name: 'Manual Trigger', + type: MANUAL_TRIGGER_NODE_TYPE, + disabled: false, + }); + const nodesList = [triggerNode]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ + nodes: nodesList, + connections, + }); + + workflowsStore.isWorkflowRunning = false; + workflowsStore.getWorkflowRunData = {}; + workflowsStore.pinDataByNodeName.mockReturnValue(undefined); + + const { nodes: mappedNodes } = useCanvasMapping({ + nodes: ref(nodesList), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + const renderOptions = mappedNodes.value[0]?.data?.render as CanvasNodeDefaultRender; + expect(renderOptions.options.tooltip).toBeUndefined(); + }); + }); + describe('connections', () => { it('should map connections to canvas connections', () => { const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts index f20a47d583..0bdbdb7315 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts @@ -299,12 +299,13 @@ export function useCanvasMapping({ if ( !!node.disabled || (triggerNodeName !== undefined && triggerNodeName !== node.name) || - !['new', 'unknown', 'waiting'].includes(nodeExecutionStatusById.value[node.id]) + !['new', 'unknown', 'waiting'].includes(nodeExecutionStatusById.value[node.id]) || + nodePinnedDataById.value[node.id] ) { return acc; } - if ('eventTriggerDescription' in nodeTypeDescription) { + if (typeof nodeTypeDescription.eventTriggerDescription === 'string') { const nodeName = i18n.shortNodeType(nodeTypeDescription.name); const { eventTriggerDescription } = nodeTypeDescription; acc[node.id] = i18n