fix(editor): Prevent tooltip flickering when a trigger node is pinned (#19233)

This commit is contained in:
Elias Meire
2025-09-15 08:06:02 +02:00
committed by GitHub
parent 70d64b73d8
commit 18d91b614b
3 changed files with 152 additions and 16 deletions

View File

@@ -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<INodeTypeDescription>({
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 }),

View File

@@ -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<Workflow>,
});
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<Workflow>,
});
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<Workflow>,
});
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<Workflow>,
});
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);

View File

@@ -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